Files
klp-oa/klp-ui/src/views/aps/calendar/index.vue
2026-03-08 16:02:44 +08:00

513 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div :class="embedded ? 'factory-calendar-board embedded' : 'factory-calendar-board app-container'">
<div class="calendar-shell">
<div class="calendar-head">
<div>
<div class="calendar-title">工厂日历排产看板</div>
<div class="calendar-sub">同一日期聚合展示全部产线×班次悬浮查看完整任务信息支持快速编辑日历</div>
</div>
<div class="head-actions">
<el-button size="mini" @click="shiftRangeDays(-pager.pageSize)">上一段</el-button>
<el-button size="mini" @click="shiftRangeDays(pager.pageSize)">下一段</el-button>
<el-button size="mini" @click="setHistoryRange">近30天</el-button>
<el-button size="mini" @click="setHybridRange">前后30天</el-button>
<el-button size="mini" type="primary" plain @click="goToday">回到今天</el-button>
<el-date-picker
v-model="jumpDate"
size="mini"
type="date"
value-format="yyyy-MM-dd"
placeholder="跳转日期"
style="width: 140px"
@change="jumpToDate"
/>
<el-select v-model="pager.pageSize" size="mini" style="width: 110px" @change="pager.pageNum = 1">
<el-option :value="7" label="每页7天" />
<el-option :value="14" label="每页14天" />
<el-option :value="28" label="每页28天" />
</el-select>
<el-button size="mini" icon="el-icon-refresh" @click="handleQuery">刷新</el-button>
</div>
</div>
<el-form v-if="!embedded" :inline="true" size="small" class="calendar-filter">
<el-form-item label="日期范围">
<el-date-picker
v-model="query.range"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
style="width: 260px"
/>
</el-form-item>
<el-form-item label="产线">
<el-select v-model="query.lineId" clearable filterable placeholder="全部产线" style="width: 220px">
<el-option
v-for="line in lineOptions"
:key="line.lineId"
:label="line.lineName || line.lineCode || ('产线' + line.lineId)"
:value="line.lineId"
/>
</el-select>
</el-form-item>
<el-form-item label="订单/产品">
<el-input v-model="query.keyword" clearable placeholder="订单号、产品名" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh-left" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="legend-wrap">
<span class="legend-title">日历图例</span>
<span class="legend-item"><i class="legend-dot day-work" />工作日</span>
<span class="legend-item"><i class="legend-dot day-rest" />休息日</span>
</div>
<div class="calendar-body" :style="boardStyle" v-loading="loading">
<el-empty v-if="!lineBoard.length || !pagedDateList.length || !shifts.length" description="暂无产线或班次基础数据" />
<div v-else class="date-grid" :style="dateGridStyle">
<div class="date-col" v-for="date in displayDateList" :key="date">
<div class="date-head">{{ date }}</div>
<div class="slot-list">
<el-tooltip
v-for="slot in getDateSlots(date)"
:key="date + '-' + slot.lineId + '-' + slot.shiftId"
placement="top"
effect="dark"
>
<div slot="content" class="slot-tooltip">
<div>产线{{ slot.lineName }}</div>
<div>班次{{ slot.shiftName }}{{ slot.startTime }}-{{ slot.endTime }}<span v-if="slot.crossDay === 1">+1</span></div>
<div>状态{{ workdayLabel(slot.lineId, date, slot.shiftId) }}</div>
<div v-if="slot.tasks.length">任务{{ slot.tasks.map(i => taskTooltipText(i)).join('') }}</div>
<div v-else>任务</div>
</div>
<div class="slot-row" :class="workdayClass(slot.lineId, date, slot.shiftId)">
<div class="slot-left">
<span class="slot-line">{{ shortText(slot.lineName, 6) }}</span>
<span class="slot-shift">{{ shortText(slot.shiftName, 4) }}</span>
</div>
<div class="slot-mid">
<span v-if="slot.tasks.length" class="task-chip" :style="{ backgroundColor: getTaskColor(slot.tasks[0]), borderColor: getTaskColor(slot.tasks[0]) }">
{{ shortText(taskDisplayName(slot.tasks[0]), 14) }}
</span>
<span v-else class="no-task">无任务</span>
<span v-if="slot.tasks.length > 1" class="task-count">+{{ slot.tasks.length - 1 }}</span>
</div>
<el-button type="text" size="mini" class="slot-edit" @click.stop="openShiftEditor(slot, date)">编辑</el-button>
</div>
</el-tooltip>
</div>
</div>
</div>
</div>
<div class="pager-wrap" v-if="dateList.length > pager.pageSize">
<el-pagination
small
background
layout="prev, pager, next, total"
:current-page.sync="pager.pageNum"
:page-size="pager.pageSize"
:total="dateList.length"
/>
</div>
</div>
<el-dialog title="编辑工厂日历班次" :visible.sync="shiftEditor.visible" width="500px" append-to-body>
<el-form ref="shiftEditorFormRef" :model="shiftEditor.form" :rules="shiftEditorRules" label-width="100px" size="small">
<el-form-item label="日期"><el-input :value="shiftEditor.meta.date" readonly /></el-form-item>
<el-form-item label="产线"><el-input :value="shiftEditor.meta.lineName" readonly /></el-form-item>
<el-form-item label="班次"><el-input :value="shiftEditor.meta.shiftName" readonly /></el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="shiftEditor.form.status">
<el-radio :label="1">工作日</el-radio>
<el-radio :label="0">停用/休息</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="计划工时" prop="plannedHours">
<el-input-number v-model="shiftEditor.form.plannedHours" :min="0" :max="24" :step="0.5" :precision="1" style="width: 100%" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="shiftEditor.form.remark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="shiftEditor.visible = false"> </el-button>
<el-button size="small" type="primary" :loading="shiftEditor.saving" @click="submitShiftEditor"> </el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { fetchFactoryCalendar, listShiftTemplate, addCalendarShift, updateCalendarShift } from '@/api/aps/aps'
import { listProductionLine } from '@/api/wms/productionLine'
const TASK_COLORS = ['#5B8FF9', '#61DDAA', '#65789B', '#F6BD16', '#7262FD', '#78D3F8', '#9661BC', '#F6903D', '#008685', '#F08BB4']
export default {
name: 'ApsFactoryCalendarBoard',
props: {
embedded: { type: Boolean, default: false },
height: { type: Number, default: 420 },
externalQuery: { type: Object, default: () => ({}) }
},
data() {
return {
loading: false,
query: { range: [], lineId: undefined, keyword: '', orderId: undefined },
jumpDate: '',
pager: { pageNum: 1, pageSize: 7 },
lineOptions: [],
shifts: [],
calendarPayload: {
overview: {},
dateList: [],
lineNameMap: {},
shiftNameMap: {},
lineDayMap: {},
lineShiftDayMap: {}
},
shiftEditor: {
visible: false,
saving: false,
meta: { configId: null, date: '', lineId: null, lineName: '', shiftId: null, shiftName: '' },
form: { status: 1, plannedHours: 8, remark: '' }
},
shiftEditorRules: {
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
plannedHours: [{ required: true, message: '请填写计划工时', trigger: 'blur' }]
}
}
},
computed: {
boardStyle() { return { minHeight: `${this.height}px` } },
dateGridStyle() {
const count = this.displayDateList.length || 1
return { gridTemplateColumns: `repeat(${count}, minmax(260px, 1fr))` }
},
lineBoard() {
const map = this.calendarPayload.lineNameMap || {}
let rows = Object.keys(map).map(id => ({ lineId: String(id), lineName: map[id] || ('产线' + id) }))
if (this.query.lineId) rows = rows.filter(i => String(i.lineId) === String(this.query.lineId))
return rows
},
dateList() {
if (!this.query.range || this.query.range.length !== 2) return []
const start = new Date(`${this.query.range[0]} 00:00:00`)
const end = new Date(`${this.query.range[1]} 00:00:00`)
const arr = []
const cursor = new Date(start.getTime())
while (cursor.getTime() <= end.getTime()) {
arr.push(this.formatDate(cursor))
cursor.setDate(cursor.getDate() + 1)
}
return arr
},
pagedDateList() {
const start = (this.pager.pageNum - 1) * this.pager.pageSize
return this.dateList.slice(start, start + this.pager.pageSize)
},
displayDateList() {
return this.pagedDateList || []
},
taskLegend() {
return []
}
},
watch: {
dateList() {
const maxPage = Math.max(1, Math.ceil(this.dateList.length / this.pager.pageSize))
if (this.pager.pageNum > maxPage) this.pager.pageNum = maxPage
},
externalQuery: {
deep: true,
immediate: true,
handler(value) {
if (!value || Object.keys(value).length === 0) return
if (value.range && value.range.length === 2) this.query.range = [value.range[0], value.range[1]]
this.query.lineId = value.lineId || undefined
this.query.orderId = value.orderId || undefined
this.query.keyword = value.keyword || ''
if (this.shifts.length) this.handleQuery()
}
}
},
created() {
if (!this.query.range.length) this.initDefaultRange()
this.loadLines()
this.loadShifts().then(() => this.handleQuery())
},
methods: {
initDefaultRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 30)
this.query.range = [this.formatDate(start), this.formatDate(end)]
},
parseDate(dateText) {
return new Date(`${dateText} 00:00:00`)
},
shiftRangeDays(delta) {
if (!this.query.range || this.query.range.length !== 2) {
this.initDefaultRange()
}
const start = this.parseDate(this.query.range[0])
const end = this.parseDate(this.query.range[1])
start.setDate(start.getDate() + delta)
end.setDate(end.getDate() + delta)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
setHistoryRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
setHybridRange() {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 30)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
goToday() {
const now = new Date()
const days = this.pager.pageSize
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - Math.floor(days / 2))
const end = new Date(start.getTime())
end.setDate(end.getDate() + days - 1)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
jumpToDate(value) {
if (!value) return
const d = this.parseDate(value)
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - Math.floor(this.pager.pageSize / 2))
const end = new Date(start.getTime())
end.setDate(end.getDate() + this.pager.pageSize - 1)
this.query.range = [this.formatDate(start), this.formatDate(end)]
this.pager.pageNum = 1
this.handleQuery()
},
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}`
},
shortText(text, maxLen) {
const s = String(text || '')
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s
},
shortTime(dt) {
if (!dt) return '--:--'
const d = new Date(dt)
if (Number.isNaN(d.getTime())) return '--:--'
return `${`${d.getHours()}`.padStart(2, '0')}:${`${d.getMinutes()}`.padStart(2, '0')}`
},
taskDisplayName(task) {
return task && task.label ? task.label : '任务'
},
taskTimeLabel(task) {
return task && task.label ? task.label : '--'
},
taskTooltipText(task) {
return task && task.title ? task.title : (task && task.label ? task.label : '--')
},
async loadLines() {
const res = await listProductionLine({ pageNum: 1, pageSize: 1000 })
this.lineOptions = res.rows || []
},
async loadShifts() {
const res = await listShiftTemplate({ pageNum: 1, pageSize: 1000 })
this.shifts = (res.rows || []).sort((a, b) => String(a.startTime).localeCompare(String(b.startTime)))
},
async handleQuery() {
if (!this.query.range || this.query.range.length !== 2) {
this.$message.warning('请先选择日期范围')
return
}
this.loading = true
try {
const params = {
queryStart: `${this.query.range[0]} 00:00:00`,
queryEnd: `${this.query.range[1]} 23:59:59`,
lineId: this.query.lineId || undefined,
orderId: this.query.orderId || undefined
}
const aggRes = await fetchFactoryCalendar(params)
this.calendarPayload = aggRes.data || {
overview: {}, dateList: [], lineNameMap: {}, shiftNameMap: {}, lineDayMap: {}, lineShiftDayMap: {}
}
} finally {
this.loading = false
}
},
resetQuery() {
this.query.lineId = undefined
this.query.orderId = undefined
this.query.keyword = ''
this.pager.pageNum = 1
this.initDefaultRange()
this.handleQuery()
},
getDateSlots(date) {
const slots = []
this.lineBoard.forEach(line => {
this.shifts.forEach(shift => {
const day = this.getLineShiftDay(line.lineId, shift.shiftId, date)
slots.push({
...line,
shiftId: shift.shiftId,
shiftName: shift.shiftName,
startTime: shift.startTime,
endTime: shift.endTime,
crossDay: shift.crossDay,
tasks: day.tasks || [],
taskCount: day.taskCount || 0,
dayStatus: day.dayStatus
})
})
})
return slots
},
workdayLabel(lineId, date, shiftId) {
const day = this.getLineShiftDay(lineId, shiftId, date)
if (day && day.dayStatus !== undefined && day.dayStatus !== null) {
return Number(day.dayStatus) === 1 ? '工作日' : '休息'
}
return '默认'
},
workdayClass(lineId, date, shiftId) {
const day = this.getLineShiftDay(lineId, shiftId, date)
if (day && day.dayStatus !== undefined && day.dayStatus !== null) {
return Number(day.dayStatus) === 1 ? 'workday' : 'restday'
}
return 'neutral'
},
openShiftEditor(slot, date) {
const day = this.getLineShiftDay(slot.lineId, slot.shiftId, date)
this.shiftEditor.meta = {
configId: day && day.configId ? day.configId : null,
date,
lineId: slot.lineId,
lineName: slot.lineName,
shiftId: slot.shiftId,
shiftName: slot.shiftName
}
this.shiftEditor.form = {
status: day && day.dayStatus !== undefined && day.dayStatus !== null ? Number(day.dayStatus) : 1,
plannedHours: day && day.plannedHours !== undefined && day.plannedHours !== null ? Number(day.plannedHours) : 8,
remark: day && day.remark ? day.remark : ''
}
this.shiftEditor.visible = true
this.$nextTick(() => {
this.$refs.shiftEditorFormRef && this.$refs.shiftEditorFormRef.clearValidate()
})
},
submitShiftEditor() {
this.$refs.shiftEditorFormRef.validate(valid => {
if (!valid) return
const payload = {
configId: this.shiftEditor.meta.configId || undefined,
calendarDate: this.shiftEditor.meta.date,
lineId: this.shiftEditor.meta.lineId,
shiftId: this.shiftEditor.meta.shiftId,
status: this.shiftEditor.form.status,
plannedHours: this.shiftEditor.form.plannedHours,
remark: this.shiftEditor.form.remark
}
const req = payload.configId ? updateCalendarShift(payload) : addCalendarShift(payload)
this.shiftEditor.saving = true
req.then(() => {
this.$message.success(payload.configId ? '日历班次已更新' : '日历班次已新增')
this.shiftEditor.visible = false
this.handleQuery()
}).finally(() => {
this.shiftEditor.saving = false
})
})
},
getLineShiftDay(lineId, shiftId, date) {
const map = this.calendarPayload.lineShiftDayMap || {}
return map[`${String(lineId)}|${String(shiftId)}|${date}`] || { taskCount: 0, dayStatus: null, tasks: [] }
},
resolveShiftRange(dateText, shift) {
if (!shift || !shift.startTime || !shift.endTime || !dateText) return null
const baseDate = new Date(`${dateText} 00:00:00`)
const [sh, sm, ss] = shift.startTime.split(':').map(v => Number(v || 0))
const [eh, em, es] = shift.endTime.split(':').map(v => Number(v || 0))
const start = new Date(baseDate.getTime())
start.setHours(sh, sm, ss, 0)
const end = new Date(baseDate.getTime())
end.setHours(eh, em, es, 0)
if (shift.crossDay === 1 || end.getTime() < start.getTime()) end.setDate(end.getDate() + 1)
return { start: start.getTime(), end: end.getTime() }
},
taskKey(task) {
return `${task.orderId || task.orderCode || ''}_${task.processId || task.processName || ''}`
},
getTaskColor(task) {
const key = this.taskKey(task)
let hash = 0
for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i)
const idx = Math.abs(hash) % TASK_COLORS.length
return TASK_COLORS[idx]
}
}
}
</script>
<style lang="scss" scoped>
.factory-calendar-board {
.calendar-shell { border: 1px solid #eef2f7; border-radius: 8px; background: #fff; padding: 10px; }
.calendar-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.calendar-title { font-size: 16px; font-weight: 600; }
.calendar-sub { margin-top: 4px; color: #909399; font-size: 12px; }
.head-actions { display: flex; gap: 8px; align-items: center; }
.calendar-filter { margin-top: 8px; margin-bottom: 6px; }
.legend-wrap { margin-bottom: 8px; font-size: 12px; color: #606266; }
.legend-title { color: #909399; margin-right: 6px; }
.legend-item { margin-right: 10px; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.legend-dot.day-work { background: #67c23a; }
.legend-dot.day-rest { background: #f56c6c; }
.date-grid { display: grid; gap: 10px; overflow-x: auto; }
.date-col { border: 1px solid #f1f4f8; border-radius: 6px; padding: 8px; background: #fff; }
.date-head { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #409eff; }
.slot-list { display: flex; flex-direction: column; gap: 6px; }
.slot-row { border: 1px solid #f2f5f9; border-radius: 4px; background: #fcfdff; padding: 4px 6px; display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.slot-row.workday { border-left: 3px solid #67c23a; background: #edf8ee; }
.slot-row.restday { border-left: 3px solid #f56c6c; background: #fff2e6; }
.slot-row.neutral { border-left: 3px solid #dcdfe6; }
.slot-left { min-width: 82px; font-size: 12px; color: #606266; display: flex; flex-direction: column; }
.slot-line { font-weight: 600; color: #303133; }
.slot-shift { color: #909399; }
.slot-mid { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; }
.task-chip { color: #fff; border: 1px solid transparent; border-radius: 10px; padding: 0 8px; font-size: 12px; line-height: 20px; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-count { color: #909399; font-size: 12px; }
.no-task { color: #c0c4cc; font-size: 12px; }
.slot-edit { padding: 0; font-size: 12px; }
.pager-wrap { margin-top: 10px; display: flex; justify-content: flex-end; }
}
.slot-tooltip {
font-size: 12px;
line-height: 1.6;
max-width: 360px;
}
.embedded { padding: 0; }
</style>