1063 lines
89 KiB
HTML
1063 lines
89 KiB
HTML
<!DOCTYPE html>
|
||
<!-- ============================================================
|
||
M-POC · "左说右动"验证原型(moduleId: poc-workbench, v3 交互版, 可重生 ✅)
|
||
目的声明:验证"左侧自然语言 → 结构化视口命令 → 右侧可视化联动"这条链路是否成立
|
||
v3 新增(对应 plan.md 最新章节):
|
||
⑥ 拖拽交互协议(§6.4a):甘特条可拖拽平移,边拖边校验(红/绿反馈),15 分钟吸附
|
||
⑦ 数据上传导入(§8.4):粘贴 CSV 订单 → 解析 → 校验预览 → 确认落库(五步管线缩影)
|
||
⑧ 主动引导(§9.6a):"猜你想问"建议椽随上一步操作动态变化(意图转移)
|
||
· 渐进披露(§6.9):甘特产线分组可折叠(折叠态显示工单/冲突摘要)、对话栏宽度可拖拽调节
|
||
验收标准(8 项):排产/视图/过滤/聚焦/高亮/拖拽/导入/引导 均可被驱动并点亮
|
||
设计要点:
|
||
- 不接 LLM:确定性意图解析器产出与 plan.md §6.3 一致的 ViewportCommand
|
||
- 数据与排产引擎复用 legacy 原型(aps-frontend/js/data.js + common.js)
|
||
- 拖拽与口令平权:两者产出同构的结构化命令并附"协议证据"(§6.4a 铁律的 POC 化)
|
||
============================================================ -->
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<!-- 声明 UTF-8 编码,保证中文口令与界面文本正常显示 -->
|
||
<meta charset="UTF-8">
|
||
<!-- 桌面优先的标准视口配置 -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<!-- 页面标题:标明这是智能排产工作台 POC -->
|
||
<title>APS 智能排产工作台 · 左说右动 POC</title>
|
||
<style>
|
||
/* ============ 设计令牌(对应 plan.md §6.0 设计系统) ============ */
|
||
:root {
|
||
--bg: #FAFAF8; /* 全局中性浅底 */
|
||
--panel: #F4F3EE; /* 侧面板底色(微暖灰) */
|
||
--card: #FFFFFF; /* 卡片纯白 */
|
||
--text: #1F1E1B; /* 主文本(近黑暖灰) */
|
||
--muted: #8A8778; /* 次要文本 */
|
||
--faint: #B7B4A8; /* 更弱文本(占位/提示) */
|
||
--border: #ECEAE3; /* 极浅边框 */
|
||
--accent: #F97316; /* 唯一强调色:工业橙 */
|
||
--accent-soft: #FFF1E7; /* 强调色浅底(激活态/用户气泡) */
|
||
--accent-deep: #EA580C; /* 强调色按压态 */
|
||
--ok: #4C9A6A; --ok-soft: #E4F2E9; /* 语义绿及浅底 */
|
||
--warn: #C9930A; --warn-soft: #FBF3D9; /* 语义黄及浅底 */
|
||
--bad: #D25353; --bad-soft: #FBE9E9; /* 语义红及浅底 */
|
||
--shadow: 0 1px 3px rgba(31,30,27,.06), 0 4px 16px rgba(31,30,27,.04); /* 低海拔柔和阴影 */
|
||
--r-card: 12px; --r-bubble: 16px; --r-ctl: 10px; /* 圆角体系:卡片/气泡/控件 */
|
||
}
|
||
/* ============ 全局基础 ============ */
|
||
* { box-sizing: border-box; } /* 统一盒模型 */
|
||
html, body { margin: 0; height: 100%; background: var(--bg); /* 满屏 + 中性底 */
|
||
color: var(--text); font-size: 14px; line-height: 1.65; /* 正文字号与行高(§6.0) */
|
||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||
body.dragging-any { user-select: none; cursor: grabbing; } /* 拖拽期间禁用选字防误选 */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; } /* 细滚动条 */
|
||
::-webkit-scrollbar-thumb { background: #DDDACF; border-radius: 8px; } /* 滚动条拇指弱化 */
|
||
::-webkit-scrollbar-track { background: transparent; } /* 轨道透明 */
|
||
|
||
/* ============ 顶栏:品牌 + 面包屑 ============ */
|
||
.topbar { height: 44px; display: flex; align-items: center; gap: 10px; /* 轻顶栏 */
|
||
padding: 0 14px; background: var(--bg); border-bottom: 1px solid var(--border); }
|
||
.brand-dot { width: 22px; height: 22px; border-radius: 7px; /* 品牌橙点 */
|
||
background: var(--accent); display: flex; align-items: center; justify-content: center;
|
||
color: #fff; font-size: 10px; font-weight: 800; }
|
||
.crumb { color: var(--muted); font-size: 12.5px; } /* 面包屑弱化 */
|
||
.crumb b { color: var(--text); font-weight: 600; } /* 当前会话名加重 */
|
||
.poc-badge { margin-left: auto; font-size: 11px; color: var(--muted); /* POC 说明徽章 */
|
||
background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
|
||
|
||
/* ============ 主体:导航轨 / 会话面板 / 对话 / 分隔条 / 视口 ============ */
|
||
.shell { display: flex; height: calc(100% - 44px); } /* 除顶栏外的全部高度 */
|
||
|
||
/* ---------- 导航轨(56px,参考 Codex 左栏) ---------- */
|
||
.rail { width: 56px; flex: 0 0 56px; background: var(--bg); /* 图标导航轨 */
|
||
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
||
align-items: center; padding: 10px 0; gap: 4px; }
|
||
.rail button { width: 40px; height: 40px; border: none; background: transparent; /* 图标按钮 */
|
||
border-radius: 10px; cursor: pointer; color: var(--muted);
|
||
display: flex; align-items: center; justify-content: center; transition: background .15s; }
|
||
.rail button:hover { background: var(--panel); } /* 悬停浅底 */
|
||
.rail button.active { background: var(--accent-soft); color: var(--accent-deep); } /* 激活:橙浅底 */
|
||
.rail .spacer { flex: 1; } /* 把设置压到底部 */
|
||
|
||
/* ---------- 项目/会话面板(可折叠) ---------- */
|
||
.sessions { width: 228px; flex: 0 0 228px; background: var(--panel); /* 会话面板 */
|
||
border-right: 1px solid var(--border); padding: 12px 10px; overflow-y: auto; }
|
||
.sessions.hidden { display: none; } /* 折叠态 */
|
||
.proj-title { font-size: 11px; color: var(--faint); font-weight: 600; /* 项目分组标题 */
|
||
letter-spacing: .5px; padding: 4px 8px; }
|
||
.sess-item { display: flex; align-items: center; gap: 8px; /* 会话条目 */
|
||
padding: 8px 10px; border-radius: 10px; cursor: pointer; font-size: 13px;
|
||
color: var(--text); margin-bottom: 2px; transition: background .15s; }
|
||
.sess-item:hover { background: rgba(255,255,255,.7); } /* 悬停微白 */
|
||
.sess-item.active { background: #fff; box-shadow: var(--shadow); font-weight: 600; } /* 当前会话浮起 */
|
||
.dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; } /* 状态点 */
|
||
.dot.run { background: var(--accent); } /* 运行中:橙 */
|
||
.dot.wait { background: var(--warn); } /* 待确认:黄 */
|
||
.dot.done { background: var(--ok); } /* 已完成:绿 */
|
||
.sess-sub { font-size: 11px; color: var(--faint); margin-left: auto; } /* 条目右侧弱信息 */
|
||
|
||
/* ---------- 中栏:对话区(宽度可由分隔条调节 §6.9) ---------- */
|
||
.chat { width: 400px; min-width: 300px; flex: 0 0 400px; /* 对话列(flex-basis 可被 JS 改写) */
|
||
display: flex; flex-direction: column; background: var(--bg); }
|
||
.splitter { width: 5px; flex: 0 0 5px; cursor: col-resize; /* 对话/视口分隔条(可拖拽调宽) */
|
||
background: var(--border); transition: background .15s; }
|
||
.splitter:hover, .splitter.active { background: var(--accent); } /* 悬停/拖动时染橙提示可拖 */
|
||
.chat-log { flex: 1; overflow-y: auto; padding: 16px 16px 4px; } /* 消息滚动区 */
|
||
.msg { margin-bottom: 14px; display: flex; gap: 8px; /* 消息容器 + 出场动效 */
|
||
animation: fadeUp .22s ease-out; }
|
||
@keyframes fadeUp { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } /* 上浮淡入 */
|
||
.msg.user { justify-content: flex-end; } /* 用户消息靠右 */
|
||
.avatar { width: 24px; height: 24px; border-radius: 8px; flex: 0 0 24px; /* 智能体头像块 */
|
||
background: var(--accent); color: #fff; font-size: 9px; font-weight: 800;
|
||
display: flex; align-items: center; justify-content: center; margin-top: 2px; }
|
||
.bubble { max-width: 86%; padding: 9px 13px; white-space: pre-wrap; /* 气泡通用 */
|
||
word-break: break-all; font-size: 13.5px; }
|
||
.msg.user .bubble { background: var(--accent-soft); color: #7C3D12; /* 用户:橙浅底胶囊 */
|
||
border-radius: var(--r-bubble); border-bottom-right-radius: 4px; }
|
||
.msg.agent .bubble { background: var(--card); border: 1px solid var(--border); /* 智能体:白卡片 */
|
||
border-radius: var(--r-bubble); border-top-left-radius: 4px; box-shadow: var(--shadow); }
|
||
.bubble details { margin-top: 8px; border-top: 1px dashed var(--border); padding-top: 6px; } /* 协议证据折叠区 */
|
||
.bubble summary { cursor: pointer; color: var(--faint); font-size: 11px; user-select: none; } /* 折叠标题 */
|
||
.bubble pre { background: #23221E; color: #C9E8B8; padding: 8px; /* 命令 JSON 深色代码块 */
|
||
border-radius: 8px; font-size: 11px; overflow-x: auto; margin: 6px 0 0; }
|
||
|
||
/* ---------- 建议椽(主动引导 §9.6a)+ 输入区 ---------- */
|
||
.chips { padding: 6px 14px 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } /* 建议椽区 */
|
||
.chips-label { font-size: 11px; color: var(--faint); } /* "猜你想问"弱标签 */
|
||
.chip { background: var(--card); color: var(--muted); border: 1px solid var(--border); /* 灰白胶囊 */
|
||
border-radius: 999px; padding: 4px 12px; cursor: pointer; font-size: 12px; transition: all .15s; }
|
||
.chip:hover { border-color: var(--accent); color: var(--accent-deep); background: var(--accent-soft); } /* 悬停染橙 */
|
||
.composer { margin: 10px 14px 8px; background: var(--card); /* 输入容器(圆角大卡片) */
|
||
border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow);
|
||
display: flex; align-items: center; gap: 4px; padding: 6px 6px 6px 10px; }
|
||
.composer:focus-within { border-color: var(--accent); } /* 聚焦描橙 */
|
||
.icon-btn { width: 30px; height: 30px; border: none; background: transparent; /* 图标小按钮 */
|
||
border-radius: 8px; color: var(--faint); cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; }
|
||
.icon-btn:hover { background: var(--panel); color: var(--muted); } /* 悬停微亮 */
|
||
.composer input { flex: 1; border: none; outline: none; font-size: 13.5px; /* 无边框输入 */
|
||
background: transparent; color: var(--text); }
|
||
.composer input::placeholder { color: var(--faint); } /* 占位符弱化 */
|
||
.send { width: 32px; height: 32px; border: none; border-radius: 10px; /* 圆角橙发送键 */
|
||
background: var(--accent); color: #fff; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; transition: background .15s; }
|
||
.send:hover { background: var(--accent-deep); } /* 按压加深 */
|
||
|
||
/* ---------- 验收清单条 ---------- */
|
||
.acceptance { padding: 6px 14px 12px; display: flex; flex-wrap: wrap; gap: 6px; } /* 验收清单容器 */
|
||
.acc-item { font-size: 11px; padding: 3px 10px; border-radius: 999px; /* 验收项胶囊(默认灰) */
|
||
background: var(--panel); color: var(--faint); border: 1px solid var(--border); transition: all .3s; }
|
||
.acc-item.done { background: var(--ok-soft); color: var(--ok); border-color: #BFE0CB; } /* 通过变绿 */
|
||
|
||
/* ---------- 右栏:视口 ---------- */
|
||
.vp { flex: 1; min-width: 0; display: flex; flex-direction: column; } /* 视口列自适应 */
|
||
.vp-toolbar { display: flex; align-items: center; gap: 10px; /* 视口工具栏 */
|
||
padding: 10px 16px; flex-wrap: wrap; }
|
||
.seg { display: flex; background: var(--panel); border-radius: 10px; /* 分段控件(模式切换) */
|
||
padding: 3px; gap: 2px; border: 1px solid var(--border); }
|
||
.seg .tab { padding: 5px 16px; border-radius: 8px; cursor: pointer; /* 分段项 */
|
||
font-size: 12.5px; color: var(--muted); transition: all .15s; }
|
||
.seg .tab.active { background: var(--card); color: var(--text); /* 激活项浮白 */
|
||
font-weight: 600; box-shadow: var(--shadow); }
|
||
.state-chip { font-size: 11.5px; background: var(--accent-soft); color: var(--accent-deep); /* 视口状态徽标 */
|
||
border-radius: 999px; padding: 3px 11px; cursor: pointer; border: 1px solid #FCD9BC; }
|
||
.state-chip.passive { background: var(--panel); color: var(--muted); border-color: var(--border); cursor: default; } /* 只读徽标 */
|
||
.kpis { margin-left: auto; display: flex; gap: 8px; } /* KPI 卡组靠右 */
|
||
.kpi { background: var(--card); border: 1px solid var(--border); /* KPI 迷你卡 */
|
||
border-radius: 10px; padding: 4px 12px; text-align: center; box-shadow: var(--shadow); }
|
||
.kpi .v { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; } /* KPI 数值 */
|
||
.kpi .l { font-size: 10px; color: var(--faint); } /* KPI 标签 */
|
||
.vp-area { flex: 1; overflow: auto; padding: 4px 16px 16px; position: relative; } /* 画布区 */
|
||
.vp-area.pulse { animation: vpPulse .7s ease-out; } /* 联动脉冲("动"的因果提示) */
|
||
@keyframes vpPulse { 0% { box-shadow: inset 0 0 0 3px rgba(249,115,22,.55); }
|
||
100% { box-shadow: inset 0 0 0 3px rgba(249,115,22,0); } }
|
||
.empty { color: var(--faint); text-align: center; padding: 80px 0; font-size: 13px; } /* 空态提示 */
|
||
|
||
/* ---------- 甘特图(含拖拽/折叠样式) ---------- */
|
||
.gantt { position: relative; background: var(--card); border-radius: var(--r-card); /* 甘特卡片 */
|
||
border: 1px solid var(--border); box-shadow: var(--shadow); padding-bottom: 10px; min-width: fit-content; }
|
||
.g-axis { position: relative; height: 28px; margin-left: 150px; border-bottom: 1px solid var(--border); } /* 时间轴 */
|
||
.g-tick { position: absolute; top: 0; font-size: 10px; color: var(--faint); /* 刻度 */
|
||
border-left: 1px dashed #F1EFE8; height: 100%; padding: 8px 0 0 4px; font-variant-numeric: tabular-nums; }
|
||
.g-lane-title { font-weight: 600; padding: 6px 12px; font-size: 12.5px; /* 产线分组标题(可点击折叠 §6.9) */
|
||
color: var(--muted); border-top: 1px solid #F6F5F0; background: #FCFBF9;
|
||
cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||
.g-lane-title:hover { color: var(--text); } /* 悬停提示可点 */
|
||
.lane-caret { font-size: 10px; transition: transform .15s; } /* 折叠箭头 */
|
||
.lane-sum { margin-left: 8px; font-weight: 400; font-size: 11px; color: var(--faint); } /* 折叠态摘要 */
|
||
.lane-badge { font-size: 10px; border-radius: 999px; padding: 1px 8px; /* 折叠态冲突徽标 */
|
||
background: var(--warn-soft); color: var(--warn); }
|
||
.g-row { display: flex; height: 32px; align-items: center; border-top: 1px solid #F8F7F3; } /* 工位行 */
|
||
.g-label { width: 150px; flex: 0 0 150px; padding: 0 12px; font-size: 11px; color: var(--muted); } /* 工位标签 */
|
||
.g-track { position: relative; height: 100%; flex: 1; } /* 排程轨道 */
|
||
.g-bar { position: absolute; top: 5px; height: 22px; border-radius: 5px; /* 工单条(低饱和底+深左缘) */
|
||
font-size: 10px; overflow: hidden; white-space: nowrap; padding: 4px 5px;
|
||
cursor: grab; border-left: 3px solid; font-variant-numeric: tabular-nums; }
|
||
.g-bar.dim { opacity: .15; } /* 高亮模式下非命中淡出 */
|
||
.g-bar.hl-red { outline: 2px solid var(--bad); opacity: 1; } /* 超期命中:红描边 */
|
||
.g-bar.hl-amber { outline: 2px solid var(--warn); opacity: 1; } /* 冲突命中:黄描边 */
|
||
.g-bar.dragging { opacity: .92; z-index: 10; cursor: grabbing; /* 拖拽中:浮起 */
|
||
box-shadow: 0 6px 16px rgba(31,30,27,.18); }
|
||
.g-bar.drag-ok { outline: 2.5px solid var(--ok); } /* 拖拽校验通过:绿描边(§6.4a) */
|
||
.g-bar.drag-bad { outline: 2.5px solid var(--bad); } /* 拖拽违反硬约束:红描边 */
|
||
.g-now { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px solid var(--accent); /* "现在"线 */
|
||
z-index: 5; opacity: .7; }
|
||
#dragTip { position: fixed; z-index: 99; pointer-events: none; /* 拖拽跟随提示(新时间+校验结论) */
|
||
background: #23221E; color: #fff; font-size: 11px; padding: 4px 10px;
|
||
border-radius: 8px; display: none; font-variant-numeric: tabular-nums; }
|
||
|
||
/* ---------- 数据表(热力/看板共用) ---------- */
|
||
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; /* 通用数据表 */
|
||
background: var(--card); border: 1px solid var(--border);
|
||
border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow); }
|
||
table.grid th, table.grid td { border-bottom: 1px solid #F6F5F0; /* 仅横向细分隔 */
|
||
padding: 8px 10px; font-size: 12.5px; text-align: center; font-variant-numeric: tabular-nums; }
|
||
table.grid th { background: #FCFBF9; color: var(--muted); font-weight: 600; font-size: 11.5px; } /* 表头弱底 */
|
||
table.grid tr:last-child td { border-bottom: none; } /* 末行无分隔线 */
|
||
.risk { border-radius: 999px; padding: 2px 12px; font-size: 11px; } /* 风险徽标 */
|
||
.risk.r-high { background: var(--bad-soft); color: var(--bad); } /* 超期:红 */
|
||
.risk.r-mid { background: var(--warn-soft); color: var(--warn); }/* 紧张:黄 */
|
||
.risk.r-low { background: var(--ok-soft); color: var(--ok); } /* 充足:绿 */
|
||
.risk.r-none { background: var(--panel); color: var(--faint); } /* 未排产:灰 */
|
||
|
||
/* ---------- 数据导入对话框(§8.4 五步管线的 POC 缩影) ---------- */
|
||
.overlay { position: fixed; inset: 0; background: rgba(31,30,27,.35); /* 遮罩层 */
|
||
display: none; align-items: center; justify-content: center; z-index: 50; }
|
||
.overlay.show { display: flex; } /* 打开态 */
|
||
.dialog { width: 640px; max-width: 92vw; max-height: 86vh; overflow-y: auto; /* 对话框卡片 */
|
||
background: var(--card); border-radius: 16px; box-shadow: 0 12px 40px rgba(31,30,27,.2); padding: 18px 20px; }
|
||
.dlg-head { display: flex; align-items: center; font-weight: 700; font-size: 15px; margin-bottom: 4px; } /* 标题行 */
|
||
.dlg-close { margin-left: auto; border: none; background: transparent; cursor: pointer; /* 关闭按钮 */
|
||
color: var(--faint); font-size: 14px; }
|
||
.dlg-sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; } /* 说明文案 */
|
||
.dialog textarea { width: 100%; height: 110px; border: 1px solid var(--border); /* CSV 粘贴区 */
|
||
border-radius: 10px; padding: 10px; font-size: 12.5px; font-family: Consolas, monospace;
|
||
resize: vertical; outline: none; }
|
||
.dialog textarea:focus { border-color: var(--accent); } /* 聚焦描橙 */
|
||
.dlg-actions { display: flex; gap: 8px; margin: 10px 0; } /* 按钮行 */
|
||
.btn-ghost { border: 1px solid var(--border); background: var(--card); color: var(--text); /* 次按钮 */
|
||
border-radius: 10px; padding: 7px 16px; cursor: pointer; font-size: 13px; }
|
||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent-deep); } /* 悬停染橙 */
|
||
.btn-primary { border: none; background: var(--accent); color: #fff; /* 主按钮 */
|
||
border-radius: 10px; padding: 7px 16px; cursor: pointer; font-size: 13px; }
|
||
.btn-primary:disabled { background: #E5E2D8; cursor: not-allowed; } /* 禁用态 */
|
||
.btn-primary:not(:disabled):hover { background: var(--accent-deep); } /* 悬停加深 */
|
||
.row-bad td { background: var(--bad-soft) !important; } /* 预览表中的异常行标红(§8.4 ④) */
|
||
.err-note { font-size: 11px; color: var(--bad); } /* 行内错误说明 */
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- ============ 顶栏:品牌 + 项目/会话面包屑 + POC 徽章 ============ -->
|
||
<div class="topbar">
|
||
<div class="brand-dot">IB</div> <!-- 工业智核品牌点 -->
|
||
<span class="crumb">青岛工厂 Q3 排产 / <b>本周主排产</b></span> <!-- 项目/会话面包屑(§4.6) -->
|
||
<span class="poc-badge">左说右动 POC v3 · 规则解析 · 未接大模型</span> <!-- 目的声明常驻 -->
|
||
</div>
|
||
|
||
<!-- ============ 主体:导航轨 / 会话面板 / 对话 / 分隔条 / 视口 ============ -->
|
||
<div class="shell">
|
||
<!-- ---------- 导航轨(图标按钮) ---------- -->
|
||
<nav class="rail">
|
||
<button class="active" title="会话">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
</button> <!-- 会话(当前激活) -->
|
||
<button title="项目列表(点击折叠/展开 §6.9)" onclick="toggleSessions()">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||
</button> <!-- 项目(切换会话面板显隐) -->
|
||
<button title="数据导入(§8.4)" onclick="openImport()">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
</button> <!-- 数据上传入口(导航轨) -->
|
||
<button title="插件管理(正式版 §6.8)">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2v6m0 8v6M2 12h6m8 0h6"/><circle cx="12" cy="12" r="3.5"/></svg>
|
||
</button> <!-- 插件管理占位 -->
|
||
<button title="技能管理(正式版 §6.8)">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polygon points="12 2 15 8.5 22 9.3 17 14 18.2 21 12 17.8 5.8 21 7 14 2 9.3 9 8.5"/></svg>
|
||
</button> <!-- 技能管理占位 -->
|
||
<div class="spacer"></div> <!-- 弹性占位 -->
|
||
<button title="设置(正式版)">
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.6h0a1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||
</button> <!-- 设置占位 -->
|
||
</nav>
|
||
|
||
<!-- ---------- 项目/会话面板(静态演示 §4.6 三级组织) ---------- -->
|
||
<aside class="sessions" id="sessionsPanel">
|
||
<div class="proj-title">青岛工厂 Q3 排产</div> <!-- 项目分组标题 -->
|
||
<div class="sess-item active"><span class="dot run"></span>本周主排产<span class="sess-sub">运行中</span></div> <!-- 当前会话 -->
|
||
<div class="sess-item" onclick="demoSession('比亚迪插单评估')"><span class="dot wait"></span>比亚迪插单评估<span class="sess-sub">待确认</span></div> <!-- 演示会话 -->
|
||
<div class="sess-item" onclick="demoSession('产线B技改产能预案')"><span class="dot done"></span>产线B技改产能预案<span class="sess-sub">已完成</span></div> <!-- 演示会话 -->
|
||
<div class="proj-title" style="margin-top:14px">后台任务</div> <!-- 后台任务分组(占位) -->
|
||
<div class="sess-item" style="cursor:default"><span class="dot done"></span>初始自动排产<span class="sess-sub">完成</span></div> <!-- 数据层自动排产的示意 -->
|
||
</aside>
|
||
|
||
<!-- ---------- 中栏:自然语言交互("说") ---------- -->
|
||
<section class="chat" id="chatPane">
|
||
<div class="chat-log" id="chatLog"></div> <!-- 对话消息滚动区 -->
|
||
<div class="chips" id="chips"></div> <!-- 建议椽(主动引导 §9.6a,JS 动态生成) -->
|
||
<div class="composer">
|
||
<button class="icon-btn" title="导入排产数据(§8.4)" onclick="openImport()">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
</button> <!-- "+":打开数据导入对话框 -->
|
||
<input id="nlInput" placeholder="用中文下指令,如:试排一版交期优先" autocomplete="off"> <!-- 口令输入框 -->
|
||
<button class="icon-btn" title="语音输入(M6 多模态 §6.7)">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
|
||
</button> <!-- 语音输入占位(麦克风) -->
|
||
<button class="send" onclick="send()" title="发送">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||
</button> <!-- 圆角橙发送键 -->
|
||
</div>
|
||
<div class="acceptance" id="acceptance"></div> <!-- 验收清单(8 项,逐项点亮) -->
|
||
</section>
|
||
|
||
<!-- ---------- 分隔条:拖拽调节对话栏宽度(§6.9 渐进披露) ---------- -->
|
||
<div class="splitter" id="splitter" title="拖拽调宽 · 双击复位"></div>
|
||
|
||
<!-- ---------- 右栏:可视化视口("动") ---------- -->
|
||
<section class="vp">
|
||
<div class="vp-toolbar">
|
||
<div class="seg">
|
||
<span class="tab" data-mode="gantt" onclick="applyCommand({cmd:'viewport.mode',params:{mode:'gantt'},issuedBy:'USER'})">甘特图</span> <!-- 手动页签与口令共用同一命令通道 -->
|
||
<span class="tab" data-mode="load" onclick="applyCommand({cmd:'viewport.mode',params:{mode:'load'},issuedBy:'USER'})">负荷热力</span> <!-- 负荷视图页签 -->
|
||
<span class="tab" data-mode="due" onclick="applyCommand({cmd:'viewport.mode',params:{mode:'due'},issuedBy:'USER'})">交期承诺</span> <!-- 交期看板页签 -->
|
||
</div>
|
||
<span id="stateChips"></span> <!-- 过滤/聚焦/高亮状态徽标(点击清除) -->
|
||
<div class="kpis" id="kpiStrip"></div> <!-- KPI 迷你卡组 -->
|
||
</div>
|
||
<div class="vp-area" id="vpArea"></div> <!-- 视口渲染区:三种视图共用 -->
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ============ 拖拽跟随提示(新时间 + 校验结论) ============ -->
|
||
<div id="dragTip"></div>
|
||
|
||
<!-- ============ 数据导入对话框(§8.4 五步管线缩影) ============ -->
|
||
<div class="overlay" id="importOverlay">
|
||
<div class="dialog" onclick="event.stopPropagation()">
|
||
<div class="dlg-head">导入排产数据 · 订单<button class="dlg-close" onclick="closeImport()">✕</button></div>
|
||
<div class="dlg-sub">① 粘贴 CSV(每行:订单号,客户,等级 VIP/A/B/C,交期 YYYY-MM-DD,产品编码 CTRL-A/B/C,数量)→ ② 解析 → ③④ 校验预览(异常行标红)→ ⑤ 确认落库(P2)。对应 plan.md §8.4 五步导入管线。</div>
|
||
<textarea id="importText">SO20260720001,美的集团,VIP,2026-07-27,CTRL-A,350
|
||
SO20260720002,格力电器,A,2026-07-29,CTRL-B,420</textarea> <!-- 预填两行示例便于一键体验 -->
|
||
<div class="dlg-actions">
|
||
<button class="btn-ghost" onclick="parseImport()">解析预览</button> <!-- 触发②③④ -->
|
||
<button class="btn-primary" id="importConfirm" disabled onclick="confirmImport()">确认导入(P2)</button> <!-- 触发⑤ -->
|
||
</div>
|
||
<div id="importPreview"></div> <!-- 校验预览表容器 -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 复用 legacy 原型的数据层(种子数据 + localStorage 持久化 + 日期工具) -->
|
||
<script src="../aps-frontend/js/data.js"></script>
|
||
<!-- 复用 legacy 原型的排产引擎(runScheduling / 冲突检测 / 班次工具) -->
|
||
<script src="../aps-frontend/js/common.js"></script>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
第 1 部分:视口状态(单一事实源)
|
||
"说"(口令)、"点"(页签)、"拖"(甘特拖拽)三个通道都只改这个对象/世界数据,
|
||
然后统一 render() —— 这就是要验证的核心机制
|
||
============================================================ */
|
||
const U = apsData.$utils; // 快捷引用日期/ID 工具集
|
||
const vp = { // 视口状态对象(唯一事实源)
|
||
mode: 'gantt', // 当前视图:gantt | load | due
|
||
filter: null, // 过滤条件:{type:'vip'} | {type:'customer',name} | null
|
||
focusLineId: null, // 聚焦产线 ID(null=全部产线)
|
||
highlight: null, // 高亮目标:'overdue' | 'conflict' | null
|
||
timescale: 'day' // 时间粒度:day | hour
|
||
};
|
||
const collapsedLines = new Set(); // 折叠的产线集合(§6.9 渐进披露)
|
||
let ganttCtx = null; // 甘特渲染上下文 {startMs, pxh}(拖拽换算用)
|
||
|
||
/* ============================================================
|
||
第 2 部分:数据查询辅助(只读,权力等级 P0)
|
||
============================================================ */
|
||
// 取最新排产版本("当前方案");无版本时返回 undefined
|
||
function latestVersion() { return apsData.scheduleVersions[apsData.scheduleVersions.length - 1]; }
|
||
|
||
// 取最新版本的全部生产订单,建立 id→PO 映射,供工单反查
|
||
function versionPOMap() {
|
||
const v = latestVersion(); // 定位最新版本
|
||
const pos = v ? apsData.productionOrders.filter(p => p.schedulingVersionId == v.id) : []; // 过滤该版本 PO
|
||
return new Map(pos.map(p => [p.id, p])); // 构建 Map 加速查找
|
||
}
|
||
|
||
// 由生产订单反查其销售订单(用于客户/交期判断)
|
||
function soOf(po) { return apsData.salesOrders.find(s => s.id == po.salesOrderId); }
|
||
|
||
// 判断生产订单是否超期:计划完成时间晚于销售订单交期当日 18:00
|
||
function isOverduePO(po) {
|
||
const so = soOf(po); // 找到对应销售订单
|
||
if (!so || !po.plannedEndDate) return false; // 数据缺失视为不超期
|
||
return U.parseDate(po.plannedEndDate) > U.parseDate(so.deliveryDate + ' 18:00'); // 比较计划完成与交期
|
||
}
|
||
|
||
// 判断工单是否带冲突:齐套非通过 或 工单/生产订单冲突计数大于 0
|
||
function hasConflict(wo, po) {
|
||
return wo.kitStatus !== 'PASSED' || (wo.conflictCount || 0) > 0 || (po && (po.conflictCount || 0) > 0);
|
||
}
|
||
|
||
// 计算"当前视口可见工单集":版本 → 聚焦产线 → 客户过滤,层层收窄
|
||
function visibleWOs() {
|
||
const poMap = versionPOMap(); // 最新版本 PO 映射
|
||
let wos = apsData.workOrders.filter(w => poMap.has(w.productionOrderId)); // 仅保留最新版本的工单
|
||
if (vp.focusLineId) wos = wos.filter(w => w.lineId == vp.focusLineId); // 应用产线聚焦
|
||
if (vp.filter) { // 应用客户维度过滤
|
||
wos = wos.filter(w => {
|
||
const so = soOf(poMap.get(w.productionOrderId)); // 工单→PO→销售订单
|
||
if (!so) return false; // 找不到订单则不显示
|
||
if (vp.filter.type === 'vip') return so.customerLevel === 'VIP'; // VIP 过滤
|
||
if (vp.filter.type === 'customer') return so.customerName.includes(vp.filter.name); // 客户名过滤
|
||
return true; // 未知过滤类型不拦截
|
||
});
|
||
}
|
||
return wos; // 返回可见工单
|
||
}
|
||
|
||
/* ============================================================
|
||
第 3 部分:确定性中文意图解析器(未来由 LLM 的 NL2Plan 替换的那一环)
|
||
============================================================ */
|
||
function parseNL(text) {
|
||
const t = text.trim(); // 去除首尾空白
|
||
// ---- 意图 1:清空数据重新初始化(危险操作,POC 中直接执行并声明) ----
|
||
if (/重置数据|清空数据|重新初始化/.test(t))
|
||
return { command: { cmd: 'data.reset', issuedBy: 'USER' }, reply: '已清空本地数据并重新生成种子数据(页面即将刷新)。' };
|
||
// ---- 意图 2:打开数据导入(多模态/文件通道的口令入口) ----
|
||
if (/导入|上传/.test(t))
|
||
return { command: { cmd: 'data.import.open', issuedBy: 'USER' }, reply: '已打开数据导入窗口:粘贴 CSV 订单 → 解析预览 → 确认落库(§8.4 五步管线)。' };
|
||
// ---- 意图 3:执行排产 / 试排(含策略与引擎识别) ----
|
||
if (/试排|排产|重排/.test(t)) {
|
||
const strategy = /交期/.test(t) ? 'DELIVERY_FIRST' // "交期优先" → 交期策略
|
||
: /均衡|产能/.test(t) ? 'CAPACITY_BALANCE' // "产能均衡" → 均衡策略
|
||
: /成本/.test(t) ? 'COST_FIRST' // "成本最优" → 成本策略
|
||
: /FIFO|先进先出/i.test(t) ? 'FIFO' // "先进先出" → FIFO
|
||
: 'COMPREHENSIVE'; // 默认综合优化
|
||
const engine = /规则/.test(t) ? 'RULE' // "用规则引擎" → RULE
|
||
: /约束|CP/i.test(t) ? 'CP' // "约束规划" → CP
|
||
: /遗传|GA/i.test(t) ? 'GA' // "遗传算法" → GA
|
||
: 'HYBRID'; // 默认混合引擎
|
||
return { command: { cmd: 'schedule.run', params: { strategy, engine }, issuedBy: 'USER' }, reply: null }; // 回复由执行器生成(含 KPI)
|
||
}
|
||
// ---- 意图 4:切换到负荷热力图 ----
|
||
if (/热力|负荷/.test(t))
|
||
return { command: { cmd: 'viewport.mode', params: { mode: 'load' }, issuedBy: 'USER' }, reply: '已把右侧切换为【负荷热力图】:颜色越深代表该产线当日负荷越高。' };
|
||
// ---- 意图 5:切换到交期承诺看板 ----
|
||
if (/交期|承诺|看板/.test(t))
|
||
return { command: { cmd: 'viewport.mode', params: { mode: 'due' }, issuedBy: 'USER' }, reply: '已把右侧切换为【交期承诺看板】:逐单展示承诺完成时间、裕量与风险。' };
|
||
// ---- 意图 6:切换回甘特图 ----
|
||
if (/甘特|排程图/.test(t))
|
||
return { command: { cmd: 'viewport.mode', params: { mode: 'gantt' }, issuedBy: 'USER' }, reply: '已把右侧切换为【甘特图】。提示:甘特条支持直接拖拽调整(§6.4a)。' };
|
||
// ---- 意图 7:过滤(VIP / 指定客户) ----
|
||
if (/只看|过滤/.test(t)) {
|
||
if (/VIP/i.test(t)) // "只看VIP订单"
|
||
return { command: { cmd: 'viewport.filter', params: { type: 'vip' }, issuedBy: 'USER' }, reply: '已过滤:右侧现在只显示 VIP 客户的工单。' };
|
||
const m = t.match(/只看(.{1,8}?)(的)?(订单|单子)/); // 提取"只看XX的订单"里的客户名
|
||
if (m) return { command: { cmd: 'viewport.filter', params: { type: 'customer', name: m[1] }, issuedBy: 'USER' }, reply: `已过滤:右侧现在只显示客户「${m[1]}」相关的工单。` };
|
||
}
|
||
// ---- 意图 8:聚焦某条产线(支持编码 L00x 或"产线A/B/C"叫法) ----
|
||
const mCode = t.match(/L00(\d)/i); // 匹配产线编码,如 L001
|
||
const mName = t.match(/产线\s*([ABC])/); // 匹配"产线A"口语
|
||
if ((/聚焦|放大|定位|只看|看看|看一下/.test(t)) && (mCode || mName)) {
|
||
const line = mCode // 优先按编码找
|
||
? apsData.lines.find(l => l.code.toUpperCase() === 'L00' + mCode[1])
|
||
: apsData.lines.find(l => l.name.endsWith(mName[1])); // 否则按名称尾字母找
|
||
if (line) return { command: { cmd: 'viewport.focus', target: String(line.id), issuedBy: 'USER' }, reply: `已聚焦到【${line.name}(${line.code})】,其他产线已隐藏。` };
|
||
}
|
||
// ---- 意图 9:高亮超期 ----
|
||
if (/超期|延迟|逾期/.test(t))
|
||
return { command: { cmd: 'viewport.highlight', params: { what: 'overdue' }, issuedBy: 'USER' }, reply: '已高亮所有【超期风险】工单(红色描边),其余淡出。' };
|
||
// ---- 意图 10:高亮冲突 ----
|
||
if (/冲突/.test(t))
|
||
return { command: { cmd: 'viewport.highlight', params: { what: 'conflict' }, issuedBy: 'USER' }, reply: null }; // 回复由执行器生成(附冲突统计)
|
||
// ---- 意图 11:切换时间粒度 ----
|
||
if (/按小时|小时粒度/.test(t))
|
||
return { command: { cmd: 'viewport.timescale', params: { scale: 'hour' }, issuedBy: 'USER' }, reply: '已切换为【小时】粒度,可横向滚动查看细节(拖拽微调更精准)。' };
|
||
if (/按天|天粒度/.test(t))
|
||
return { command: { cmd: 'viewport.timescale', params: { scale: 'day' }, issuedBy: 'USER' }, reply: '已切换为【天】粒度,自动适配窗口宽度。' };
|
||
// ---- 意图 12:重置视口(清除过滤/聚焦/高亮) ----
|
||
if (/重置|清除|复位/.test(t))
|
||
return { command: { cmd: 'viewport.reset', issuedBy: 'USER' }, reply: '已重置视口:清除全部过滤、聚焦与高亮。' };
|
||
// ---- 意图 13:查询 KPI(纯问答,不动视口) ----
|
||
if (/指标|KPI|利用率|情况怎么样|怎么样/i.test(t))
|
||
return { command: { cmd: 'query.kpi', issuedBy: 'USER' }, reply: null }; // 回复由执行器生成
|
||
// ---- 意图 14:帮助 ----
|
||
if (/帮助|能做什么|help/i.test(t))
|
||
return { command: null, reply: helpText() }; // 帮助不产生视口命令
|
||
// ---- 兜底:未识别意图 ----
|
||
return { command: null, reply: '这句口令我还没学会。可以试试:\n' + helpText() };
|
||
}
|
||
|
||
// 帮助文案:列出本 POC 支持的全部口令族
|
||
function helpText() {
|
||
return [
|
||
'· 试排一版交期优先 / 产能均衡重排 / 用规则引擎试排',
|
||
'· 切到负荷热力图 / 交期承诺看板 / 甘特图',
|
||
'· 只看VIP订单 / 只看海尔智家的订单',
|
||
'· 聚焦产线A / 看看L003',
|
||
'· 标出超期的 / 看看冲突',
|
||
'· 按小时看 / 按天看 / 重置视图',
|
||
'· 导入订单数据 / 现在的KPI怎么样 / 重置数据',
|
||
'· 甘特图上可直接拖拽工单条;点产线标题可折叠分组'
|
||
].join('\n');
|
||
}
|
||
|
||
/* ============================================================
|
||
第 4 部分:命令执行器("动"的一侧)
|
||
============================================================ */
|
||
function applyCommand(command) {
|
||
let reply = null; // 执行器可覆盖回复文案(如附带 KPI)
|
||
let suggestKey = null; // 本次命令对应的建议椽上下文键(§9.6a)
|
||
switch (command.cmd) { // 按命令类型分派
|
||
case 'schedule.run': { // —— 执行排产(P1:写入的是演示数据沙盒)——
|
||
const tomorrow = U.fmtDate(U.addMinutes(U.today(), 24 * 60)); // 默认从明天开始排
|
||
const v = runScheduling({ // 调用 legacy 引擎真实排产
|
||
orderIds: [], // 空数组=全部待排订单
|
||
engineType: command.params.engine, // 引擎类型(解析器识别)
|
||
strategyTemplate: command.params.strategy, // 策略模板(解析器识别)
|
||
planningHorizonDays: 14, // 计划展望期 14 天
|
||
startDate: tomorrow, // 排产起始日
|
||
constraints: { materialKit: true, equipment: true, personnel: true, changeover: true } // 全约束开启
|
||
});
|
||
vp.mode = 'gantt'; // 排完自动切回甘特展示结果
|
||
reply = `排产完成 ✅ 版本 ${v.versionNo}(${STATUS_LABELS[v.engineType] || v.engineType} · ${command.params.strategy})\n` + // 汇报版本与引擎
|
||
`生产订单 ${v.poCount} 个 / 工单 ${v.woCount} 个\n` + // 汇报生成规模
|
||
`冲突 ${v.conflictCount} 项 · 总延迟 ${Math.round(v.totalTardiness)}h · 平均利用率 ${Math.round(v.avgUtilization * 100)}%\n` + // 汇报 KPI
|
||
`右侧甘特已刷新,可直接拖拽工单条微调。`; // 声明右侧联动 + 引导拖拽
|
||
markDone('schedule'); // 点亮验收项:排产指令
|
||
suggestKey = 'schedule.run'; // 建议椽:排产后的高频后继
|
||
break;
|
||
}
|
||
case 'viewport.mode': // —— 切换视图模式 ——
|
||
vp.mode = command.params.mode; // 更新单一事实源
|
||
markDone('mode'); // 点亮验收项:视图切换
|
||
suggestKey = 'mode.' + command.params.mode; // 建议椽:按目标视图给后继
|
||
break;
|
||
case 'viewport.filter': // —— 设置过滤 ——
|
||
vp.filter = command.params; // 记录过滤条件
|
||
markDone('filter'); // 点亮验收项:过滤
|
||
suggestKey = 'filter'; // 建议椽:过滤后的后继
|
||
break;
|
||
case 'viewport.focus': // —— 聚焦产线 ——
|
||
vp.focusLineId = Number(command.target); // 记录聚焦产线 ID
|
||
markDone('focus'); // 点亮验收项:聚焦
|
||
suggestKey = 'focus'; // 建议椽:聚焦后的后继
|
||
break;
|
||
case 'viewport.highlight': { // —— 高亮超期/冲突 ——
|
||
vp.highlight = command.params.what; // 记录高亮目标
|
||
if (command.params.what === 'conflict') { // 冲突高亮时附带统计回复
|
||
const unresolved = apsData.conflicts.filter(c => !c.isResolved); // 统计未解决冲突
|
||
const top = unresolved.slice(0, 3).map(c => `· [${STATUS_LABELS[c.severity] || c.severity}] ${c.description}`).join('\n'); // 摘录前 3 条
|
||
reply = `当前共有 ${unresolved.length} 项未解决冲突,已在右侧以黄色描边标出相关工单。\n${top || '(无冲突明细)'}`; // 组装回复
|
||
}
|
||
markDone('highlight'); // 点亮验收项:高亮
|
||
suggestKey = 'highlight.' + command.params.what; // 建议椽:按高亮类型给后继
|
||
break;
|
||
}
|
||
case 'viewport.timescale': // —— 切换时间粒度 ——
|
||
vp.timescale = command.params.scale; // 更新粒度
|
||
break;
|
||
case 'viewport.reset': // —— 重置视口 ——
|
||
vp.filter = null; vp.focusLineId = null; vp.highlight = null; // 清空三类状态(保留模式与粒度)
|
||
suggestKey = 'default'; // 建议椽:回到默认全集
|
||
break;
|
||
case 'query.kpi': { // —— 查询 KPI(只读问答)——
|
||
const v = latestVersion(); // 取最新版本
|
||
reply = v ? `当前版本 ${v.versionNo}:\n生产订单 ${v.poCount} / 工单 ${v.woCount}\n冲突 ${v.conflictCount} 项 · 总延迟 ${Math.round(v.totalTardiness)}h\n平均利用率 ${Math.round(v.avgUtilization * 100)}% · 预估成本 ¥${Math.round(v.totalCost).toLocaleString()}` // 有版本时汇报 KPI
|
||
: '还没有排产版本,先说"试排一版"吧。'; // 无版本时引导
|
||
break;
|
||
}
|
||
case 'data.import.open': // —— 打开数据导入对话框 ——
|
||
openImport(); // 弹出导入窗口(后续步骤在窗口内完成)
|
||
break;
|
||
case 'data.reset': // —— 清空数据重来(POC 便捷操作)——
|
||
localStorage.removeItem('aps_frontend_data_v1'); // 删除持久化数据
|
||
setTimeout(() => location.reload(), 400); // 稍后刷新以重建种子数据
|
||
break;
|
||
}
|
||
render(); // 任何命令执行后统一重绘右侧视口
|
||
pulseViewport(); // 触发一次联动脉冲(让用户看见"动"在哪)
|
||
if (suggestKey) updateSuggestions(suggestKey); // 按意图转移更新建议椽(§9.6a)
|
||
return reply; // 把执行器生成的回复交还给对话层
|
||
}
|
||
|
||
// 联动脉冲:给视口区加一次性的橙色内描边动画,强化"左说右动"的因果感知
|
||
function pulseViewport() {
|
||
const area = document.getElementById('vpArea'); // 视口区引用
|
||
area.classList.remove('pulse'); // 先移除旧动画类(允许连续触发)
|
||
void area.offsetWidth; // 强制回流以重启 CSS 动画
|
||
area.classList.add('pulse'); // 添加脉冲动画类
|
||
}
|
||
|
||
/* ============================================================
|
||
第 5 部分:右侧视口渲染(三种视图共用 render 入口)
|
||
============================================================ */
|
||
// 渲染总入口:按 vp.mode 分派,并同步工具栏状态
|
||
function render() {
|
||
document.querySelectorAll('.tab').forEach(el => // 同步页签激活态
|
||
el.classList.toggle('active', el.dataset.mode === vp.mode));
|
||
renderStateChips(); // 同步状态徽标
|
||
renderKpiStrip(); // 同步 KPI 摘要
|
||
const area = document.getElementById('vpArea'); // 视口容器
|
||
if (vp.mode === 'gantt') renderGantt(area); // 甘特视图
|
||
else if (vp.mode === 'load') renderLoad(area); // 负荷热力视图
|
||
else renderDue(area); // 交期看板视图
|
||
}
|
||
|
||
// 渲染工具栏上的"当前视口状态"徽标(点击即清除对应状态)
|
||
function renderStateChips() {
|
||
const el = document.getElementById('stateChips'); // 徽标容器
|
||
const chips = []; // 待渲染徽标集合
|
||
if (vp.filter) chips.push(`<span class="state-chip" onclick="vp.filter=null;render()">过滤:${vp.filter.type === 'vip' ? 'VIP' : vp.filter.name} ✕</span>`); // 过滤徽标
|
||
if (vp.focusLineId) { const l = apsData.lines.find(x => x.id == vp.focusLineId); chips.push(`<span class="state-chip" onclick="vp.focusLineId=null;render()">聚焦:${l ? l.name : vp.focusLineId} ✕</span>`); } // 聚焦徽标
|
||
if (vp.highlight) chips.push(`<span class="state-chip" onclick="vp.highlight=null;render()">高亮:${vp.highlight === 'overdue' ? '超期' : '冲突'} ✕</span>`); // 高亮徽标
|
||
chips.push(`<span class="state-chip passive">粒度:${vp.timescale === 'day' ? '天' : '小时'}</span>`); // 粒度徽标(仅展示)
|
||
el.innerHTML = chips.join(' '); // 写入 DOM
|
||
}
|
||
|
||
// 渲染 KPI 迷你卡组(最新版本核心指标常驻可见)
|
||
function renderKpiStrip() {
|
||
const v = latestVersion(); // 取最新版本
|
||
const kpi = (val, label) => `<div class="kpi"><div class="v">${val}</div><div class="l">${label}</div></div>`; // KPI 卡模板
|
||
document.getElementById('kpiStrip').innerHTML = v // 有版本则展示四张迷你卡
|
||
? kpi(v.woCount, '工单') + kpi(v.conflictCount, '冲突') + kpi(Math.round(v.totalTardiness) + 'h', '总延迟') + kpi(Math.round(v.avgUtilization * 100) + '%', '利用率')
|
||
: `<div class="kpi"><div class="v">—</div><div class="l">暂无版本</div></div>`; // 无版本占位
|
||
}
|
||
|
||
// 低饱和色板(§6.0 数据呈现规范):浅底 + 深左缘 + 深字,按产品 ID 稳定取色
|
||
const PALETTE = [
|
||
{ bg: '#FBD9B8', edge: '#E0872F', fg: '#6B3D0E' }, // 产品色 1:暖橙
|
||
{ bg: '#C4DDF2', edge: '#4A8AC0', fg: '#1E3E58' }, // 产品色 2:静蓝
|
||
{ bg: '#CBE6D1', edge: '#4C9A6A', fg: '#1F4630' }, // 产品色 3:柔绿
|
||
{ bg: '#DED3F0', edge: '#8B6FC0', fg: '#3D2E5C' }, // 产品色 4:淡紫
|
||
{ bg: '#F1E4AF', edge: '#C9930A', fg: '#5C4505' }, // 产品色 5:麦黄
|
||
{ bg: '#F4CFDD', edge: '#C9628C', fg: '#5C2039' } // 产品色 6:浅粉
|
||
];
|
||
function barStyle(productId) { return PALETTE[productId % PALETTE.length]; } // 取模映射色组
|
||
|
||
// 视图 1:甘特图 —— 行=产线/工位,条=工单,支持聚焦/过滤/高亮/粒度/折叠/拖拽
|
||
function renderGantt(area) {
|
||
const wos = visibleWOs(); // 取当前可见工单
|
||
if (!wos.length) { area.innerHTML = '<div class="empty">当前视口没有可显示的工单<br>试试对我说"重置视图"或"试排一版"</div>'; return; } // 空态兜底
|
||
const poMap = versionPOMap(); // PO 映射(判超期/冲突用)
|
||
let minT = Infinity, maxT = -Infinity; // 计算时间域边界
|
||
wos.forEach(w => { // 遍历工单求最早开始与最晚结束
|
||
minT = Math.min(minT, U.parseDate(w.plannedStartTime).getTime()); // 更新最早开始
|
||
maxT = Math.max(maxT, U.parseDate(w.plannedEndTime).getTime()); // 更新最晚结束
|
||
});
|
||
const start = new Date(minT); start.setHours(0, 0, 0, 0); // 时间域向前对齐到当日零点
|
||
const end = new Date(maxT); end.setHours(24, 0, 0, 0); // 时间域向后对齐到次日零点
|
||
const hours = (end - start) / 3600000; // 总小时数
|
||
const availW = area.clientWidth - 150 - 48; // 可用轨道宽度(减标签列与留白)
|
||
const pxh = vp.timescale === 'hour' ? 28 : Math.max(availW / hours, 1.2); // 像素/小时
|
||
const trackW = Math.ceil(hours * pxh); // 轨道总宽
|
||
ganttCtx = { startMs: start.getTime(), pxh }; // 保存渲染上下文供拖拽换算(§6.4a)
|
||
// ---- 组装时间轴刻度 ----
|
||
const stepH = vp.timescale === 'hour' ? 6 : 24; // 刻度间隔小时数
|
||
let axis = ''; // 刻度 HTML 累加器
|
||
for (let h = 0; h <= hours; h += stepH) { // 逐刻度生成
|
||
const t = new Date(start.getTime() + h * 3600000); // 刻度对应时刻
|
||
const label = vp.timescale === 'hour' // 小时粒度显示"日 时",天粒度显示"月-日"
|
||
? (t.getHours() === 0 ? U.fmtDate(t).slice(5) : t.getHours() + ':00')
|
||
: U.fmtDate(t).slice(5);
|
||
axis += `<span class="g-tick" style="left:${Math.round(h * pxh)}px">${label}</span>`; // 绝对定位刻度
|
||
}
|
||
// ---- 逐产线分组(可折叠 §6.9)、逐工位成行生成工单条 ----
|
||
const lines = apsData.lines.filter(l => !vp.focusLineId || l.id == vp.focusLineId); // 聚焦时只留目标产线
|
||
let rows = ''; // 行 HTML 累加器
|
||
lines.forEach(line => { // 遍历产线
|
||
const lineWOs = wos.filter(w => w.lineId == line.id); // 该产线可见工单
|
||
if (!lineWOs.length && vp.focusLineId == null) return; // 非聚焦模式下空产线不占位
|
||
const collapsed = collapsedLines.has(line.id); // 该产线是否处于折叠态
|
||
const confCnt = lineWOs.filter(w => hasConflict(w, poMap.get(w.productionOrderId))).length; // 折叠摘要用冲突数
|
||
const summary = collapsed // 折叠态在标题行内联"工单数+冲突徽标"摘要
|
||
? `<span class="lane-sum">工单 ${lineWOs.length}</span>${confCnt ? `<span class="lane-badge">冲突 ${confCnt}</span>` : ''}` : '';
|
||
rows += `<div class="g-lane-title" onclick="toggleLane(${line.id})"><span class="lane-caret">${collapsed ? '▸' : '▾'}</span>${line.name}(${line.code})${summary}</div>`; // 分组标题(点击折叠)
|
||
if (collapsed) return; // 折叠态跳过工位行(折叠不丢信息:摘要+徽标兜底)
|
||
const wss = apsData.workstations.filter(w => w.lineId == line.id) // 该产线全部工位
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo); // 按工序顺序排列
|
||
wss.forEach(ws => { // 遍历工位成行
|
||
const wsWOs = lineWOs.filter(w => w.workstationId == ws.id); // 落在该工位的工单
|
||
const bars = wsWOs.map(w => { // 生成工单条
|
||
const s = U.parseDate(w.plannedStartTime).getTime(); // 工单开始时刻
|
||
const e = U.parseDate(w.plannedEndTime).getTime(); // 工单结束时刻
|
||
const left = Math.round((s - start.getTime()) / 3600000 * pxh); // 条左偏移
|
||
const width = Math.max(Math.round((e - s) / 3600000 * pxh), 5); // 条宽(保底 5px 可见)
|
||
const po = poMap.get(w.productionOrderId); // 反查生产订单
|
||
const overdue = isOverduePO(po); // 是否超期
|
||
const conflict = hasConflict(w, po); // 是否冲突
|
||
let cls = ''; // 高亮样式类
|
||
if (vp.highlight === 'overdue') cls = overdue ? 'hl-red' : 'dim'; // 超期模式
|
||
if (vp.highlight === 'conflict') cls = conflict ? 'hl-amber' : 'dim'; // 冲突模式
|
||
const c = barStyle(w.productId); // 取该产品的低饱和色组
|
||
const tip = `${w.orderNo} · ${w.productName}×${w.quantity}\n${w.operationName} @ ${w.workstationName}\n${w.plannedStartTime} ~ ${w.plannedEndTime}\n(可拖拽调整 §6.4a)${overdue ? '\n⚠ 超期风险' : ''}${conflict ? '\n⚠ 存在冲突/缺料' : ''}`; // 悬停提示
|
||
return `<div class="g-bar ${cls}" data-wo="${w.id}" title="${tip}" style="left:${left}px;width:${width}px;background:${c.bg};border-left-color:${c.edge};color:${c.fg}">${w.orderNo}</div>`; // 条 DOM(data-wo 供拖拽定位)
|
||
}).join(''); // 拼接该工位全部条
|
||
rows += `<div class="g-row"><div class="g-label">${ws.name}</div><div class="g-track" style="width:${trackW}px">${bars}</div></div>`; // 输出工位行
|
||
});
|
||
});
|
||
// ---- "现在"竖线 ----
|
||
const now = Date.now(); // 当前时间戳
|
||
const nowLine = (now > start.getTime() && now < end.getTime()) // 仅当"现在"落在时间域内才画
|
||
? `<div class="g-now" style="left:${Math.round(150 + (now - start.getTime()) / 3600000 * pxh)}px"></div>` : '';
|
||
area.innerHTML = `<div class="gantt" style="min-width:${150 + trackW}px">${nowLine}<div class="g-axis" style="width:${trackW}px">${axis}</div>${rows}</div>`; // 一次性写入 DOM
|
||
}
|
||
|
||
// 折叠/展开某条产线分组(§6.9 渐进披露),状态存内存(正式版按项目×用户持久化)
|
||
function toggleLane(lineId) {
|
||
if (collapsedLines.has(lineId)) collapsedLines.delete(lineId); // 已折叠 → 展开
|
||
else collapsedLines.add(lineId); // 未折叠 → 折叠
|
||
render(); // 重绘甘特
|
||
}
|
||
|
||
// 视图 2:负荷热力图 —— 行=产线,列=未来 14 天,颜色深浅=负荷率
|
||
function renderLoad(area) {
|
||
const wos = visibleWOs(); // 可见工单(同样吃过滤/聚焦)
|
||
const days = []; // 未来 14 天日期集合
|
||
for (let i = 0; i < 14; i++) days.push(U.fmtDate(U.addMinutes(U.today(), i * 24 * 60))); // 逐日生成
|
||
const lines = apsData.lines.filter(l => !vp.focusLineId || l.id == vp.focusLineId); // 聚焦时只留目标产线
|
||
let head = '<tr><th style="text-align:left">产线 \\ 日期</th>' + days.map(d => `<th>${d.slice(5)}</th>`).join('') + '</tr>'; // 表头行
|
||
let body = lines.map(line => { // 逐产线生成一行
|
||
const cells = days.map(d => { // 逐日生成单元格
|
||
const used = wos.filter(w => w.lineId == line.id && U.fmtDate(w.plannedStartTime) === d) // 当日该产线工单
|
||
.reduce((s, w) => s + (U.parseDate(w.plannedEndTime) - U.parseDate(w.plannedStartTime)) / 60000, 0); // 累计占用分钟
|
||
const avail = getAvailableMinutes(line.id, d); // 当日可用分钟(班次日历)
|
||
if (!avail) return '<td style="background:#F7F6F1;color:#CFCCC0">休</td>'; // 无班次(周末)显示"休"
|
||
const ratio = used / avail; // 负荷率
|
||
const bg = ratio === 0 ? '#FFFFFF' // 0 负荷:白
|
||
: ratio < .5 ? 'var(--ok-soft)' : ratio < .8 ? 'var(--warn-soft)' // 低负荷绿、中负荷黄
|
||
: ratio <= 1 ? '#FFE8D5' : 'var(--bad-soft)'; // 高负荷橙、超载红
|
||
const fg = ratio > 1 ? 'var(--bad)' : 'var(--text)'; // 超载数字标红
|
||
return `<td style="background:${bg};color:${fg}" title="占用 ${Math.round(used)} / 可用 ${Math.round(avail)} 分钟">${Math.round(ratio * 100)}%</td>`; // 单元格含悬停明细
|
||
}).join(''); // 拼接 14 天
|
||
return `<tr><td style="text-align:left;font-weight:600">${line.name}</td>${cells}</tr>`; // 产线行
|
||
}).join(''); // 拼接全部产线
|
||
area.innerHTML = `<table class="grid">${head}${body}</table>`; // 写入 DOM
|
||
}
|
||
|
||
// 视图 3:交期承诺看板 —— 逐销售订单展示承诺完成、裕量与风险等级
|
||
function renderDue(area) {
|
||
const poMap = versionPOMap(); // 最新版本 PO 映射
|
||
const pos = [...poMap.values()]; // PO 数组
|
||
let orders = apsData.salesOrders.filter(o => o.status !== 'CANCELLED'); // 有效销售订单
|
||
if (vp.filter) { // 看板同样响应过滤(联动一致性)
|
||
orders = orders.filter(o => vp.filter.type === 'vip' ? o.customerLevel === 'VIP' : o.customerName.includes(vp.filter.name));
|
||
}
|
||
const rows = orders.map(o => { // 逐订单生成一行
|
||
const myPOs = pos.filter(p => p.salesOrderId == o.id); // 该订单在当前版本的 PO
|
||
const promised = myPOs.length // 承诺完成时间=各 PO 计划完成的最大值
|
||
? myPOs.map(p => p.plannedEndDate).sort().slice(-1)[0] : null;
|
||
const due = U.parseDate(o.deliveryDate + ' 18:00'); // 交期基准(当日 18:00)
|
||
let risk = '<span class="risk r-none">未排产</span>', margin = '—'; // 默认:未排产
|
||
if (promised) { // 已排产则计算裕量与风险
|
||
const m = (due - U.parseDate(promised)) / 3600000; // 裕量小时数(正=提前,负=超期)
|
||
margin = (m >= 0 ? '+' : '') + m.toFixed(1) + 'h'; // 格式化裕量
|
||
risk = m < 0 ? '<span class="risk r-high">超期</span>' // 负裕量:超期红
|
||
: m < 24 ? '<span class="risk r-mid">紧张</span>' // 24h 内:紧张黄
|
||
: '<span class="risk r-low">充足</span>'; // 其余:充足绿
|
||
}
|
||
return `<tr><td>${o.orderNo}</td><td>${o.customerName}</td><td>${o.customerLevel}${o.isRush ? ' ⚡插单' : ''}</td><td>${o.deliveryDate}</td><td>${promised ? promised.slice(0, 16) : '—'}</td><td>${margin}</td><td>${risk}</td></tr>`; // 输出行
|
||
}).join(''); // 拼接全部订单
|
||
area.innerHTML = `<table class="grid"><tr><th>订单号</th><th>客户</th><th>等级</th><th>交期</th><th>承诺完成</th><th>裕量</th><th>风险</th></tr>${rows}</table>`; // 写入 DOM
|
||
}
|
||
|
||
/* ============================================================
|
||
第 6 部分:拖拽交互(§6.4a 的 POC 化)
|
||
手势:按住甘特条水平拖动 → 逐帧校验(红/绿描边+跟随提示)→ 松手落子或弹回
|
||
============================================================ */
|
||
let dragState = null; // 当前拖拽会话(null=未在拖拽)
|
||
|
||
// POC 版移动校验:只查"硬约束"三项(班次/维保/同工位重叠),范围限定当前版本工单
|
||
function checkMovePOC(wo, newStart) {
|
||
const dur = U.parseDate(wo.plannedEndTime) - U.parseDate(wo.plannedStartTime); // 保持工时不变
|
||
const newEnd = new Date(newStart.getTime() + dur); // 推算新结束时间
|
||
const conflicts = []; // 冲突收集器
|
||
const dateStr = U.fmtDate(newStart); // 目标日期
|
||
if (!getLineShifts(wo.lineId, dateStr).length) // 硬约束①:目标日无班次
|
||
conflicts.push('目标日期无工作班次');
|
||
const eq = apsData.equipment.find(e => e.workstationId == wo.workstationId); // 该工位设备
|
||
if (eq) apsData.maintenance.filter(m => m.equipmentId == eq.id).forEach(m => { // 硬约束②:设备维保重叠
|
||
const ms = U.parseDate(m.plannedStart), me = U.parseDate(m.plannedEnd); // 维保时段
|
||
if (newStart < me && newEnd > ms) conflicts.push(`与设备维保「${m.description}」时间冲突`);
|
||
});
|
||
const poMap = versionPOMap(); // 只跟当前版本的工单比重叠(避免历史版本残留误报)
|
||
const overlap = apsData.workOrders.some(o => // 硬约束③:同工位时间重叠
|
||
o.id != wo.id && o.workstationId == wo.workstationId && poMap.has(o.productionOrderId) &&
|
||
newStart < U.parseDate(o.plannedEndTime) && newEnd > U.parseDate(o.plannedStartTime));
|
||
if (overlap) conflicts.push('与同工位其他工单时间重叠');
|
||
return { canApply: !conflicts.length, conflicts, newEnd }; // 返回校验结论
|
||
}
|
||
|
||
// 拖拽起手:事件委托在视口区,按 data-wo 定位工单
|
||
document.getElementById('vpArea').addEventListener('mousedown', e => {
|
||
const barEl = e.target.closest('.g-bar'); // 命中工单条?
|
||
if (!barEl || vp.mode !== 'gantt' || !ganttCtx) return; // 仅甘特视图可拖
|
||
const wo = apsData.workOrders.find(w => w.id == barEl.dataset.wo); // 定位工单对象
|
||
if (!wo) return; // 数据缺失防御
|
||
e.preventDefault(); // 阻止默认选字/拖图
|
||
dragState = { // 建立拖拽会话
|
||
wo, // 被拖工单
|
||
el: barEl, // 条 DOM
|
||
startX: e.clientX, // 起手鼠标 X
|
||
origLeft: parseFloat(barEl.style.left), // 原始 left(像素)
|
||
origStartMs: U.parseDate(wo.plannedStartTime).getTime(), // 原始开始时刻
|
||
lastCheck: null, // 最近一次校验结论
|
||
moved: false // 是否发生了有效位移
|
||
};
|
||
barEl.classList.add('dragging'); // 视觉:浮起
|
||
document.body.classList.add('dragging-any'); // 全局:禁选字
|
||
});
|
||
|
||
// 拖拽进行中:换算新时间 → 15 分钟吸附 → 逐帧校验 → 红/绿反馈 + 跟随提示
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragState) return; // 未在拖拽则忽略
|
||
const dx = e.clientX - dragState.startX; // 水平位移
|
||
if (Math.abs(dx) > 3) dragState.moved = true; // 超过 3px 视为有效拖动(区分点击)
|
||
const rawMs = dragState.origStartMs + dx / ganttCtx.pxh * 3600000; // 位移换算为新开始时刻
|
||
const snapMs = Math.round(rawMs / 900000) * 900000; // 吸附 15 分钟网格(§6.4a 硬规则①)
|
||
const snapDate = new Date(snapMs); // 吸附后的开始时间
|
||
const newLeft = dragState.origLeft + (snapMs - dragState.origStartMs) / 3600000 * ganttCtx.pxh; // 反算像素位置
|
||
dragState.el.style.left = newLeft + 'px'; // 移动幽灵条
|
||
const check = checkMovePOC(dragState.wo, snapDate); // 逐帧硬约束校验(边拖边校验)
|
||
dragState.lastCheck = { ...check, snapDate }; // 记录最近校验结论
|
||
dragState.el.classList.toggle('drag-ok', check.canApply); // 可行:绿描边
|
||
dragState.el.classList.toggle('drag-bad', !check.canApply); // 违规:红描边
|
||
const tip = document.getElementById('dragTip'); // 跟随提示元素
|
||
tip.style.display = 'block'; // 显示提示
|
||
tip.style.left = (e.clientX + 14) + 'px'; // 跟随鼠标(右下偏移)
|
||
tip.style.top = (e.clientY + 16) + 'px';
|
||
tip.textContent = `${U.fmtDateTime(snapDate)} ${check.canApply ? '✓ 可行' : '✗ ' + check.conflicts[0]}`; // 新时间+结论
|
||
});
|
||
|
||
// 拖拽收手:可行→落子提交(产出结构化命令+协议证据);违规→弹回并解释
|
||
document.addEventListener('mouseup', () => {
|
||
if (!dragState) return; // 未在拖拽则忽略
|
||
const { wo, el, lastCheck, moved } = dragState; // 解构拖拽会话
|
||
document.getElementById('dragTip').style.display = 'none'; // 隐藏跟随提示
|
||
document.body.classList.remove('dragging-any'); // 恢复选字
|
||
el.classList.remove('dragging', 'drag-ok', 'drag-bad'); // 清除拖拽视觉
|
||
const state = dragState; dragState = null; // 结束会话(防重入)
|
||
if (!moved || !state.lastCheck) { render(); return; } // 无有效位移=点击,弹回即可
|
||
if (lastCheck.canApply) { // —— 可行:提交移动(POC 直接落库;正式版为 P1 草稿→P2 门禁)——
|
||
const command = { // 拖拽产出与口令同构的结构化命令(§6.4a 平权原则)
|
||
cmd: 'workorder.move', issuedBy: 'USER_DRAG', // 命令名 + 来源标注为拖拽
|
||
params: { woId: wo.id, orderNo: wo.orderNo, // 载荷:目标工单
|
||
from: wo.plannedStartTime, // 原开始时间
|
||
to: U.fmtDateTime(lastCheck.snapDate) } // 新开始时间(已吸附)
|
||
};
|
||
wo.plannedStartTime = U.fmtDateTime(lastCheck.snapDate); // 写入新开始时间
|
||
wo.plannedEndTime = U.fmtDateTime(lastCheck.newEnd); // 写入新结束时间(工时不变)
|
||
U.save(apsData); // 持久化到 localStorage
|
||
render(); pulseViewport(); // 重绘 + 联动脉冲
|
||
addMsg('agent', `已通过拖拽将 ${wo.orderNo} 移动到 ${command.params.to}(硬约束校验通过 ✓)。\n正式版中这将进入变更草稿暂存区,聚合后经 P2 门禁确认提交(§6.4a)。`, command); // 回执+协议证据
|
||
markDone('drag'); // 点亮验收项:拖拽
|
||
updateSuggestions('drag'); // 拖拽后的建议椽
|
||
} else { // —— 违规:弹回原位并解释原因 ——
|
||
render(); // 重绘=弹回
|
||
addMsg('agent', `这次拖拽被硬约束拦截(§3.5),已弹回原位:\n· ${lastCheck.conflicts.join('\n· ')}\n要我帮你找一个可行时段吗?(正式版能力)`); // 解释性回复(§9.6a 时机性提示)
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
第 7 部分:数据导入(§8.4 五步管线的 POC 缩影)
|
||
============================================================ */
|
||
let importRows = null; // 解析后的待导入行集(含校验结果)
|
||
|
||
// ① 打开导入对话框
|
||
function openImport() { document.getElementById('importOverlay').classList.add('show'); }
|
||
// 关闭导入对话框并清空预览
|
||
function closeImport() {
|
||
document.getElementById('importOverlay').classList.remove('show'); // 隐藏遮罩
|
||
document.getElementById('importPreview').innerHTML = ''; // 清空预览表
|
||
document.getElementById('importConfirm').disabled = true; // 复位确认按钮
|
||
importRows = null; // 清空解析缓存
|
||
}
|
||
// 点击遮罩空白处关闭
|
||
document.getElementById('importOverlay').addEventListener('click', closeImport);
|
||
|
||
// ②③④ 解析 + 校验 + 预览(异常行标红,正式版此处是 LLM 辅助表头映射 + Schema 校验)
|
||
function parseImport() {
|
||
const lines = document.getElementById('importText').value.split('\n').map(l => l.trim()).filter(Boolean); // 按行拆分
|
||
importRows = lines.map(line => { // 逐行解析
|
||
const f = line.split(/[,,]/).map(x => x.trim()); // 兼容中英文逗号
|
||
const row = { orderNo: f[0], customer: f[1], level: (f[2] || '').toUpperCase(), delivery: f[3], code: (f[4] || '').toUpperCase(), qty: Number(f[5]), errors: [] }; // 结构化行
|
||
if (f.length !== 6) row.errors.push('字段数须为 6'); // 校验:字段数
|
||
if (!/^[A-Z0-9]{6,20}$/i.test(row.orderNo || '')) row.errors.push('订单号格式'); // 校验:订单号
|
||
if (!['VIP', 'A', 'B', 'C'].includes(row.level)) row.errors.push('等级须为 VIP/A/B/C'); // 校验:等级枚举
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.delivery || '')) row.errors.push('交期格式 YYYY-MM-DD'); // 校验:日期格式
|
||
else if (U.parseDate(row.delivery + ' 18:00') < new Date()) row.errors.push('交期早于今天'); // 校验:交期合理性
|
||
row.product = apsData.materials.find(m => m.type === 'FINISHED_PRODUCT' && m.code === row.code); // 校验:产品编码存在性(引用完整性)
|
||
if (!row.product) row.errors.push(`产品编码 ${row.code || '?'} 不存在`);
|
||
if (!(row.qty > 0)) row.errors.push('数量须为正数'); // 校验:数量为正
|
||
if (apsData.salesOrders.some(o => o.orderNo === row.orderNo)) row.errors.push('订单号已存在(去重)'); // 校验:去重
|
||
return row; // 返回带校验结果的行
|
||
});
|
||
const ok = importRows.filter(r => !r.errors.length).length; // 统计有效行
|
||
const html = `<table class="grid"><tr><th>订单号</th><th>客户</th><th>等级</th><th>交期</th><th>产品</th><th>数量</th><th>校验</th></tr>` +
|
||
importRows.map(r => `<tr class="${r.errors.length ? 'row-bad' : ''}"><td>${r.orderNo || '—'}</td><td>${r.customer || '—'}</td><td>${r.level || '—'}</td><td>${r.delivery || '—'}</td><td>${r.product ? r.product.name : (r.code || '—')}</td><td>${r.qty || '—'}</td><td>${r.errors.length ? `<span class="err-note">${r.errors.join(';')}</span>` : '✓'}</td></tr>`).join('') + `</table>
|
||
<div class="dlg-sub" style="margin-top:8px">有效 ${ok} 行 / 共 ${importRows.length} 行 —— 仅有效行会被导入(异常行按 §8.4 规则丢弃)</div>`; // 预览表+统计
|
||
document.getElementById('importPreview').innerHTML = html; // 渲染预览
|
||
document.getElementById('importConfirm').disabled = ok === 0; // 至少 1 行有效才可确认
|
||
}
|
||
|
||
// ⑤ 确认落库(P2 语义:POC 直接写入并留"审计式"回执;正式版走门禁确认卡)
|
||
function confirmImport() {
|
||
const valid = (importRows || []).filter(r => !r.errors.length); // 取有效行
|
||
if (!valid.length) return; // 防御:无有效行
|
||
const todayStr = U.fmtDate(new Date()); // 下单日期=今天
|
||
valid.forEach(r => { // 逐行落库为销售订单
|
||
const id = U.nextId('salesOrder'); // 分配订单 ID
|
||
apsData.salesOrders.push({ // 构造订单对象(对齐 data.js 结构)
|
||
id, orderNo: r.orderNo, customerId: 'CUST-IMP-' + id, // 基本标识
|
||
customerName: r.customer, customerLevel: r.level, // 客户信息
|
||
orderDate: todayStr, deliveryDate: r.delivery, // 日期
|
||
priority: { VIP: 1, A: 3, B: 5, C: 8 }[r.level], // 等级映射默认优先级
|
||
manualPriority: null, status: 'CONFIRMED', source: 'MANUAL', // 状态:已确认、来源:人工导入
|
||
specialRequirements: '', totalAmount: r.qty * 150, // 金额(演示估算)
|
||
isRush: false, rushStrategy: null, changes: [], // 非插单默认值
|
||
createdBy: 'import', createdAt: todayStr + ' 09:00', updatedAt: todayStr + ' 09:00', // 留痕
|
||
items: [{ id: id * 10 + 1, orderId: id, lineNo: 1, // 单行订单项
|
||
productId: r.product.id, productName: r.product.name, productCode: r.product.code,
|
||
quantity: r.qty, unit: '件', bomVersion: 'V1.0', routingVersion: 'V1.0', status: 'PENDING', note: '数据导入' }]
|
||
});
|
||
});
|
||
apsData.logs.unshift({ id: U.nextId('log'), type: 'INFO', category: 'ORDER', operationType: 'IMPORT', // 写导入日志(审计雏形 §3.6)
|
||
targetType: 'SALES_ORDER', targetId: valid[0].orderNo, action: '数据导入',
|
||
description: `导入订单 ${valid.length} 行(数据集:手工粘贴 CSV)`, operator: 'import', createdAt: U.fmtDateTime(new Date()) });
|
||
U.save(apsData); // 持久化
|
||
closeImport(); // 关闭对话框
|
||
const command = { cmd: 'data.import.commit', issuedBy: 'USER', // 导入也是结构化命令(进证据链)
|
||
params: { rows: valid.length, orderNos: valid.map(r => r.orderNo) } };
|
||
addMsg('agent', `数据导入完成 ✅ 新增 ${valid.length} 张订单(${valid.map(r => r.orderNo).join('、')})。\n数据集已登记(版本/血缘/质量报告见正式版 §8.4)。\n这些订单还未排产 —— 建议现在试排一版把它们排进去。`, command); // 回执+引导(§9.6a)
|
||
markDone('upload'); // 点亮验收项:导入
|
||
vp.mode = 'due'; render(); pulseViewport(); // 切到交期看板:新订单以"未排产"可见
|
||
updateSuggestions('import'); // 导入后的建议椽
|
||
}
|
||
|
||
/* ============================================================
|
||
第 8 部分:对话层("说"的一侧)+ 主动引导(§9.6a)
|
||
============================================================ */
|
||
// 追加一条消息到对话流;cmdObj 非空时附带可展开的结构化命令 JSON(协议证据)
|
||
function addMsg(role, text, cmdObj) {
|
||
const log = document.getElementById('chatLog'); // 对话容器
|
||
const proof = cmdObj // 组装"视口命令"折叠块
|
||
? `<details><summary>▸ 结构化命令(协议证据)</summary><pre>${JSON.stringify(cmdObj, null, 2)}</pre></details>` : '';
|
||
const avatar = role === 'agent' ? '<div class="avatar">IB</div>' : ''; // 智能体消息带品牌头像块
|
||
log.insertAdjacentHTML('beforeend', // 追加气泡 DOM
|
||
`<div class="msg ${role}">${avatar}<div class="bubble">${text}${proof}</div></div>`);
|
||
log.scrollTop = log.scrollHeight; // 滚动到底部
|
||
}
|
||
|
||
// 处理一句用户口令:解析 → 执行 → 回复(完整的"说→动"闭环)
|
||
function handle(text) {
|
||
const { command, reply } = parseNL(text); // 第一步:确定性解析出结构化命令
|
||
if (!command) { addMsg('agent', reply); return; } // 无命令(帮助/未识别)直接回复
|
||
const execReply = applyCommand(command); // 第二步:执行命令并联动右侧视口
|
||
addMsg('agent', execReply || reply || '已执行。', command); // 第三步:回复用户并附协议证据
|
||
}
|
||
|
||
// 发送按钮/回车的统一入口
|
||
function send() {
|
||
const input = document.getElementById('nlInput'); // 输入框引用
|
||
const text = input.value.trim(); // 读取并裁剪口令
|
||
if (!text) return; // 空输入不处理
|
||
addMsg('user', text); // 先回显用户消息
|
||
input.value = ''; // 清空输入框
|
||
handle(text); // 走"说→动"闭环
|
||
}
|
||
// 绑定回车发送
|
||
document.getElementById('nlInput').addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||
|
||
// ---- 主动引导:意图转移表(上一步操作 → 高频后继建议,§9.6a 的 POC 化) ----
|
||
const QUICK = ['试排一版交期优先', '只看VIP订单', '标出超期的', '看看冲突', '切到负荷热力图', '交期承诺看板', '聚焦产线A', '导入订单数据', '重置视图']; // 默认全集
|
||
const SUGGEST = { // 意图转移映射:key=刚发生的事,value=建议的下一步
|
||
'schedule.run': ['看看冲突', '标出超期的', '交期承诺看板'], // 排完产 → 查冲突/超期/交期
|
||
'highlight.conflict': ['交期承诺看板', '产能均衡重排', '聚焦产线A'], // 看完冲突 → 换策略重排或定位产线
|
||
'highlight.overdue': ['试排一版交期优先', '交期承诺看板'], // 看完超期 → 交期优先重排
|
||
'mode.due': ['只看VIP订单', '标出超期的'], // 看交期板 → 聚焦 VIP/超期
|
||
'mode.load': ['聚焦产线A', '按小时看'], // 看热力 → 下钻产线/细粒度
|
||
'mode.gantt': ['标出超期的', '看看冲突'], // 看甘特 → 标风险
|
||
'filter': ['标出超期的', '重置视图'], // 过滤后 → 标超期或复位
|
||
'focus': ['按小时看', '重置视图'], // 聚焦后 → 细看或复位
|
||
'drag': ['看看冲突', '现在的KPI怎么样', '交期承诺看板'], // 拖完 → 复查冲突/KPI
|
||
'import': ['试排一版综合优化', '交期承诺看板'], // 导入后 → 排进去
|
||
'default': QUICK // 兜底:默认全集
|
||
};
|
||
// 更新建议椽:suggest 模式带"猜你想问"标签;点击建议椽计入"引导"验收
|
||
function updateSuggestions(key) {
|
||
const list = SUGGEST[key] || QUICK; // 取该上下文的建议集
|
||
const isSuggest = key !== 'default'; // 是否为"猜测"态(区别于默认全集)
|
||
document.getElementById('chips').innerHTML = // 渲染标签+胶囊+还原入口
|
||
(isSuggest ? '<span class="chips-label">猜你想问</span>' : '<span class="chips-label">快捷指令</span>') +
|
||
list.map(q => `<span class="chip" onclick="chipClick('${q}',${isSuggest})">${q}</span>`).join('') +
|
||
(isSuggest ? `<span class="chip" title="显示全部指令" onclick="updateSuggestions('default')">…</span>` : '');
|
||
}
|
||
// 建议椽点击:回显+执行;若点的是"猜测"出的建议 → 点亮"引导"验收项(采纳即偏好信号 §9.6a)
|
||
function chipClick(q, isSuggest) {
|
||
if (isSuggest) markDone('guide'); // 采纳主动建议 → 验收⑧通过
|
||
addMsg('user', q); // 回显为用户消息
|
||
handle(q); // 走"说→动"闭环
|
||
}
|
||
|
||
/* ============================================================
|
||
第 9 部分:外壳交互(会话面板折叠 / 分隔条调宽 / 演示会话)
|
||
============================================================ */
|
||
// 折叠/展开项目会话面板(导航轨"项目"按钮触发,§6.9)
|
||
function toggleSessions() { document.getElementById('sessionsPanel').classList.toggle('hidden'); }
|
||
// 点击演示会话:POC 中仅提示,多会话切换属正式版能力(§4.6)
|
||
function demoSession(name) { addMsg('agent', `会话「${name}」的切换与引用(@会话)将在正式版实现(plan.md §4.6/4.7),本 POC 聚焦验证"左说右动"链路。`); }
|
||
|
||
// 分隔条拖拽:调节对话栏宽度(§6.9"对话栏↔视口"可调;双击复位 400px)
|
||
(function initSplitter() {
|
||
const sp = document.getElementById('splitter'); // 分隔条元素
|
||
const chat = document.getElementById('chatPane'); // 对话栏元素
|
||
let dragging = false, startX = 0, startW = 400; // 拖拽会话变量
|
||
sp.addEventListener('mousedown', e => { // 起手:记录基准
|
||
dragging = true; startX = e.clientX; // 记录起点
|
||
startW = chat.getBoundingClientRect().width; // 记录当前宽度
|
||
sp.classList.add('active'); // 视觉:染橙
|
||
document.body.classList.add('dragging-any'); // 禁选字
|
||
e.preventDefault(); // 阻止默认行为
|
||
});
|
||
document.addEventListener('mousemove', e => { // 进行中:实时改写 flex-basis
|
||
if (!dragging) return; // 未拖动忽略
|
||
const w = Math.min(560, Math.max(300, startW + e.clientX - startX)); // 限幅 300~560px
|
||
chat.style.flex = `0 0 ${w}px`; // 应用新宽度
|
||
});
|
||
document.addEventListener('mouseup', () => { // 收手:结束会话并重绘甘特
|
||
if (!dragging) return; // 未拖动忽略
|
||
dragging = false; sp.classList.remove('active'); // 清理视觉
|
||
document.body.classList.remove('dragging-any'); // 恢复选字
|
||
render(); // 视口宽度变了 → 甘特自适应重绘
|
||
});
|
||
sp.addEventListener('dblclick', () => { chat.style.flex = '0 0 400px'; render(); }); // 双击复位默认宽
|
||
})();
|
||
|
||
/* ============================================================
|
||
第 10 部分:验收清单(目的声明与验收的 POC 化,v3 扩至 8 项)
|
||
============================================================ */
|
||
const ACCEPTANCE = { // 八项验收标准(对应 plan.md M-POC + v3 新增)
|
||
schedule: '① 排产', // 口令触发真实排产
|
||
mode: '② 视图', // 口令切换三种视图
|
||
filter: '③ 过滤', // 口令按客户/VIP 过滤
|
||
focus: '④ 聚焦', // 口令聚焦产线
|
||
highlight: '⑤ 高亮', // 口令高亮超期/冲突
|
||
drag: '⑥ 拖拽', // 甘特拖拽+边拖边校验(§6.4a)
|
||
upload: '⑦ 导入', // 数据上传五步管线(§8.4)
|
||
guide: '⑧ 引导' // 采纳"猜你想问"建议(§9.6a)
|
||
};
|
||
const accDone = {}; // 已通过项记录
|
||
// 渲染验收清单胶囊
|
||
function renderAcceptance() {
|
||
document.getElementById('acceptance').innerHTML = // 逐项输出,done 变绿
|
||
Object.entries(ACCEPTANCE).map(([k, label]) =>
|
||
`<span class="acc-item ${accDone[k] ? 'done' : ''}">${accDone[k] ? '✓ ' : ''}${label}</span>`).join('');
|
||
}
|
||
// 标记某验收项通过并刷新清单
|
||
function markDone(key) { if (!accDone[key]) { accDone[key] = true; renderAcceptance(); } }
|
||
|
||
/* ============================================================
|
||
第 11 部分:初始化
|
||
============================================================ */
|
||
renderAcceptance(); // 初始渲染验收清单(全灰)
|
||
updateSuggestions('default'); // 初始建议椽=默认全集
|
||
addMsg('agent', // 欢迎语:说明玩法与 v3 新能力
|
||
'你好,我是排产助手(POC v3,规则解析、未接大模型)。\n左边说,右边动 —— 这一版还能:\n· 直接拖拽甘特条调整工单(边拖边校验,红绿反馈)\n· 点"+"或说"导入订单数据"粘贴 CSV 导入\n· 点产线标题折叠分组;拖中间分隔条调宽\n· 每步操作后下方会"猜你想问"\n\n' + helpText());
|
||
render(); // 首次渲染右侧视口(数据层已自动完成初始排产)
|
||
window.addEventListener('resize', render); // 窗口变化时重绘(甘特自适应宽度)
|
||
</script>
|
||
</body>
|
||
</html>
|