aps-agent/aps-frontend/pages/calendar.html

177 lines
9.3 KiB
HTML
Raw Permalink Normal View History

2026-07-21 11:05:57 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>排产日历 - 智能排产系统</title>
<link rel="stylesheet" href="../css/common.css">
<style>
.gantt-wrap { min-height: 320px; }
.gantt-header { display: flex; border-bottom: 1px solid var(--border); background: #fafafa; }
.gantt-header .gantt-label { border-right: 1px solid var(--border); font-weight: 700; }
.gantt-header .gantt-track { display: flex; }
.day-slot { flex: 1; border-right: 1px dashed #e5e7eb; padding: 6px; font-size: 12px; color: var(--text-muted); text-align: center; }
.drag-tip { position: fixed; background: rgba(31,41,55,0.9); color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 100; display: none; }
</style>
</head>
<body>
<div id="app">
<header class="header" id="header"></header>
<div class="body">
<aside class="sidebar" id="sidebar"></aside>
<main class="main">
<h1 class="page-title">排产日历</h1>
<div class="toolbar">
<button class="btn" onclick="changeDate(-7)">← 上一周</button>
<button class="btn" onclick="changeDate(7)">下一周 →</button>
<span id="rangeLabel" class="text-muted"></span>
<select id="viewLevel" class="btn" style="padding:7px 10px;margin-left:auto" onchange="render()">
<option value="LINE">产线视图</option>
<option value="WORKSTATION">工位视图</option>
</select>
<button class="btn btn-primary" onclick="location.href='scheduling.html'">重新排产</button>
</div>
<div class="card gantt-wrap" id="ganttCard">
<div class="gantt-header">
<div class="gantt-label">资源</div>
<div class="gantt-track" id="dayHeader"></div>
</div>
<div id="ganttBody"></div>
</div>
<div class="card">
<div class="card-header">图例</div>
<div class="card-body flex gap-3">
<span class="badge badge-primary">正常</span>
<span class="badge badge-danger">冲突</span>
<span class="badge" style="background:#9ca3af;color:#fff">已下发冻结</span>
<span class="badge badge-success">已完成</span>
<span class="text-muted">提示:拖拽工单条可调整时间,红色表示不可放置</span>
</div>
</div>
</main>
</div>
</div>
<div class="drag-tip" id="dragTip"></div>
<script src="../js/data.js"></script>
<script src="../js/common.js"></script>
<script>
initPage('calendar');
let viewStart = apsData.$utils.today();
const days = 7;
function changeDate(offset) {
viewStart = apsData.$utils.addMinutes(viewStart, offset * 24 * 60);
render();
}
function render() {
const level = document.getElementById('viewLevel').value;
const end = apsData.$utils.addMinutes(viewStart, (days - 1) * 24 * 60);
document.getElementById('rangeLabel').textContent = `${apsData.$utils.fmtDate(viewStart)} ~ ${apsData.$utils.fmtDate(end)}`;
const header = document.getElementById('dayHeader');
header.innerHTML = '';
for (let i = 0; i < days; i++) {
const d = apsData.$utils.addMinutes(viewStart, i * 24 * 60);
const div = document.createElement('div'); div.className = 'day-slot'; div.textContent = apsData.$utils.fmtDate(d).slice(5) + ' ' + ['日','一','二','三','四','五','六'][d.getDay()];
header.appendChild(div);
}
const body = document.getElementById('ganttBody');
body.innerHTML = '';
const resources = level === 'WORKSTATION' ? apsData.workstations : apsData.lines;
const trackWidth = header.clientWidth || 800;
const pxPerHour = trackWidth / (days * 24);
const viewStartMs = viewStart.getTime();
resources.forEach(res => {
const row = document.createElement('div'); row.className = 'gantt-row';
const label = document.createElement('div'); label.className = 'gantt-label'; label.textContent = res.name;
const track = document.createElement('div'); track.className = 'gantt-track';
track.style.width = trackWidth + 'px';
for (let i = 0; i < days; i++) {
const slot = document.createElement('div'); slot.className = 'slot'; track.appendChild(slot);
}
const wos = apsData.workOrders.filter(wo => {
if (level === 'WORKSTATION') return wo.workstationId == res.id;
return wo.lineId == res.id;
});
wos.forEach(wo => {
const s = apsData.$utils.parseDate(wo.plannedStartTime);
const e = apsData.$utils.parseDate(wo.plannedEndTime);
const left = ((s.getTime() - viewStartMs) / 3600000) * pxPerHour;
const width = Math.max(((e - s) / 3600000) * pxPerHour, 20);
if (left + width < 0 || left > trackWidth) return;
const bar = document.createElement('div');
bar.className = 'gantt-bar' + (wo.conflictCount ? ' conflict' : '') + (wo.isFrozen ? ' frozen' : '') + (wo.status === 'COMPLETED' ? ' completed' : '');
bar.style.left = Math.max(0, left) + 'px';
bar.style.width = Math.max(width, 20) + 'px';
bar.textContent = `${wo.productName} #${wo.quantity}`;
bar.title = `${wo.orderNo}\n${wo.operationName}\n${wo.plannedStartTime} ~ ${wo.plannedEndTime}`;
bar.onclick = (ev) => { ev.stopPropagation(); openWoDetail(wo); };
makeDraggable(bar, wo, pxPerHour, viewStartMs);
track.appendChild(bar);
});
row.appendChild(label); row.appendChild(track); body.appendChild(row);
});
if (!resources.length) body.innerHTML = '<div class="empty-state">暂无资源数据</div>';
}
function makeDraggable(bar, wo, pxPerHour, viewStartMs) {
let startX, startLeft, dragging = false;
bar.addEventListener('mousedown', e => {
if (wo.isFrozen || wo.status === 'COMPLETED') return;
dragging = true; startX = e.clientX; startLeft = parseFloat(bar.style.left) || 0;
bar.style.zIndex = 10; bar.style.opacity = 0.8;
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', e => {
if (!dragging) return;
const dx = e.clientX - startX;
const newLeft = startLeft + dx;
bar.style.left = Math.max(0, newLeft) + 'px';
const newStart = new Date(viewStartMs + (newLeft / pxPerHour) * 3600000);
const check = checkMoveConflict(wo, apsData.$utils.fmtDateTime(newStart));
const tip = document.getElementById('dragTip');
tip.style.display = 'block';
tip.style.left = (e.clientX + 12) + 'px';
tip.style.top = (e.clientY - 30) + 'px';
tip.style.background = check.canApply ? '#16a34a' : '#dc2626';
tip.textContent = check.canApply ? '可放置' : check.conflicts.map(c => c.message).join('; ');
});
window.addEventListener('mouseup', e => {
if (!dragging) return;
dragging = false; bar.style.zIndex = ''; bar.style.opacity = 1; document.body.style.userSelect = '';
document.getElementById('dragTip').style.display = 'none';
const newLeft = parseFloat(bar.style.left) || 0;
const newStart = new Date(viewStartMs + (newLeft / pxPerHour) * 3600000);
const check = checkMoveConflict(wo, apsData.$utils.fmtDateTime(newStart));
if (check.canApply) {
const dur = apsData.$utils.parseDate(wo.plannedEndTime) - apsData.$utils.parseDate(wo.plannedStartTime);
wo.plannedStartTime = apsData.$utils.fmtDateTime(newStart);
wo.plannedEndTime = apsData.$utils.fmtDateTime(new Date(newStart.getTime() + dur));
apsData.$utils.save(apsData);
showToast('调整成功', `工单 ${wo.orderNo} 已更新`);
render();
} else {
showToast('无法放置', check.conflicts.map(c => c.message).join('; '), 'error');
render();
}
});
}
function openWoDetail(wo) {
const po = findById(apsData.productionOrders, wo.productionOrderId);
openModal('工单详情', `
<div class="form-row"><div class="form-group"><label>工单号</label><input value="${wo.orderNo}" readonly></div><div class="form-group"><label>工序</label><input value="${wo.operationName}" readonly></div></div>
<div class="form-row"><div class="form-group"><label>产品</label><input value="${wo.productName} × ${wo.quantity}" readonly></div><div class="form-group"><label>产线/工位</label><input value="${wo.lineName} / ${wo.workstationName}" readonly></div></div>
<div class="form-row"><div class="form-group"><label>计划开始</label><input value="${wo.plannedStartTime}" readonly></div><div class="form-group"><label>计划结束</label><input value="${wo.plannedEndTime}" readonly></div></div>
<div class="form-row"><div class="form-group"><label>状态</label><input value="${STATUS_LABELS[wo.status] || wo.status}" readonly></div><div class="form-group"><label>齐套</label><input value="${STATUS_LABELS[wo.kitStatus] || wo.kitStatus}" readonly></div></div>
<p><strong>所属生产订单:</strong>${po ? po.orderNo : '-'}</p>
`);
}
window.addEventListener('resize', render);
setTimeout(render, 50);
</script>
</body>
</html>