aps-agent/poc/workbench.html

1063 lines
89 KiB
HTML
Raw Permalink 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.

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