feat: 订餐终端设备概览新增餐线营养秤Tab + 净菜订单新增生成历史/生成方式列/就餐人数

- din-terminal.html: 设备概览新增「餐线营养秤」Tab(四维度子页切换),表头列筛选 + 人脸识别视频回放弹窗 + 运行日志报错详情
- prod-clean-order.html: 新增「净菜订单生成历史」按钮与弹窗、主列表「生成方式」列、详情抽屉自动订单「就餐人数」列
- 新增文档: 异常场景素材-秤体视角 / 加工厂硬件流程说明 / 角色场景流程图 / 营养秤多维状态矩阵
This commit is contained in:
zhangyan
2026-08-27 09:00:12 +08:00
parent 059d1c82ab
commit 40e0b311c4
7 changed files with 1314 additions and 8 deletions
+378 -1
View File
@@ -76,6 +76,7 @@ tbody tr:hover{background:#F8FBFF;}
.tag-orange{background:#FFF7E6;color:var(--warning);}
.tag-red{background:#FFF1F0;color:var(--danger);}
.tag-gray{background:#F5F5F5;color:#999;}
.tag-purple{background:#F9F0FF;color:#722ED1;}
.link{color:var(--primary);cursor:pointer;font-size:var(--font-sm);}.link:hover{color:#40a9ff;}
.pagination{display:flex;align-items:center;justify-content:flex-end;padding:16px 24px;gap:8px;border-top:1px solid var(--border);}
.pagination span{font-size:var(--font-sm);color:var(--text-secondary);}
@@ -109,7 +110,7 @@ tbody tr:hover{background:#F8FBFF;}
.stat-card__label{font-size:var(--font-sm);color:var(--text-secondary);}
.action-bar{display:flex;align-items:center;gap:8px;margin-bottom:16px;}
/* 动态弹窗基础 */
.dyn-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1010;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;}
.dyn-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1100;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s;}
.dyn-overlay.show{opacity:1;}
.dyn-modal{background:#fff;border-radius:var(--radius-lg);max-height:80vh;display:flex;flex-direction:column;transform:translateY(30px);transition:transform .25s;box-shadow:0 12px 40px rgba(0,0,0,0.18);}
.dyn-overlay.show .dyn-modal{transform:translateY(0);}
@@ -311,6 +312,46 @@ tbody tr:hover{background:#F8FBFF;}
/* Tab 内嵌区块标题 */
.ov-section-title{font-size:var(--font-base);font-weight:600;color:var(--text-primary);margin:0 0 12px;display:flex;align-items:center;gap:6px;}
.ov-section-title::before{content:'';display:inline-block;width:3px;height:15px;background:var(--primary);border-radius:2px;}
/* 表头列筛选 */
.th-filter{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:4px;border-radius:3px;color:#c0c4cc;font-size:10px;cursor:pointer;vertical-align:-3px;transition:all .15s;}
.th-filter:hover{color:var(--primary);background:#e8f1ff;}
.th-filter--active{color:var(--primary);background:#e8f1ff;}
.th-filter-pop{position:fixed;z-index:1700;min-width:150px;background:#fff;border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 6px 20px rgba(0,0,0,.15);padding:6px;max-height:240px;overflow-y:auto;}
.th-filter-pop__item{padding:6px 10px;font-size:var(--font-sm);color:var(--text-primary);border-radius:4px;cursor:pointer;white-space:nowrap;}
.th-filter-pop__item:hover{background:#f5f8ff;}
.th-filter-pop__item.active{color:var(--primary);background:#e8f1ff;font-weight:500;}
/* ===== 餐线营养秤概览(子页切换) ===== */
.scale-sub-tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid var(--border);}
.scale-sub-tab{padding:8px 16px;font-size:var(--font-sm);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;user-select:none;transition:all .15s;}
.scale-sub-tab:hover{color:var(--primary);}
.scale-sub-tab.active{color:var(--primary);font-weight:600;border-bottom-color:var(--primary);}
/* ===== 人脸识别视频回放弹窗 ===== */
@keyframes ovScan{0%{top:10%}50%{top:88%}100%{top:10%}}
.face-video-mask{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1600;display:flex;align-items:center;justify-content:center;animation:ovFadeIn .2s ease;}
.face-video-mask.closing{animation:ovFadeOut .2s ease forwards;}
.face-video-modal{width:680px;max-width:92vw;max-height:86vh;background:#fff;border-radius:var(--radius-md);box-shadow:0 12px 40px rgba(0,0,0,.22);display:flex;flex-direction:column;overflow:hidden;animation:ovModalIn .22s ease;}
.face-video-mask.closing .face-video-modal{animation:ovModalOut .18s ease forwards;}
.face-video-modal__header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;}
.face-video-modal__title{font-size:var(--font-base);font-weight:600;color:var(--text-primary);}
.face-video-modal__close{width:32px;height:32px;border:none;background:none;font-size:20px;cursor:pointer;border-radius:var(--radius-sm);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;}
.face-video-modal__close:hover{background:#f5f5f5;color:var(--text-primary);}
.face-video-modal__body{padding:20px;overflow-y:auto;flex:1;}
.face-video-player{position:relative;background:#141a24;border-radius:var(--radius-md);height:280px;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.face-video-player__scan{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#40a9ff,transparent);animation:ovScan 3s linear infinite;}
.face-video-player__badge{position:absolute;top:12px;left:14px;font-size:10px;color:#fff;background:rgba(255,77,79,.85);padding:2px 8px;border-radius:10px;z-index:1;}
.face-video-player__meta{position:absolute;bottom:12px;left:14px;right:14px;display:flex;justify-content:space-between;color:rgba(255,255,255,.65);font-size:var(--font-xs);font-family:monospace;z-index:1;}
.face-video-player__play{width:52px;height:52px;border-radius:50%;background:rgba(24,144,255,.18);border:2px solid rgba(24,144,255,.7);display:flex;align-items:center;justify-content:center;color:#40a9ff;cursor:pointer;z-index:1;font-size:18px;}
.face-video-controls{display:flex;align-items:center;gap:10px;padding:14px 4px 4px;}
.face-video-btn{height:30px;padding:0 12px;border:1px solid var(--border);background:#fff;border-radius:var(--radius-sm);cursor:pointer;font-size:var(--font-xs);color:var(--text-secondary);white-space:nowrap;}
.face-video-btn:hover{border-color:var(--primary);color:var(--primary);}
.face-video-btn--primary{min-width:36px;padding:0;display:flex;align-items:center;justify-content:center;font-size:14px;border-color:var(--primary);color:var(--primary);}
.face-video-bar{flex:1;height:6px;background:#f0f0f0;border-radius:3px;overflow:hidden;cursor:pointer;}
.face-video-bar__progress{height:100%;background:var(--primary);border-radius:3px;width:0;}
.face-video-time{font-size:var(--font-xs);color:var(--text-secondary);font-family:monospace;white-space:nowrap;}
.face-video-info{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-top:12px;padding-top:14px;border-top:1px solid #f0f0f0;}
.face-video-info__item{font-size:var(--font-sm);}
.face-video-info__item .label{display:block;font-size:var(--font-xs);color:var(--text-placeholder);margin-bottom:4px;}
.face-video-info__item strong{color:var(--text-primary);font-weight:600;}
</style>
</head>
<body>
@@ -668,6 +709,7 @@ document.querySelectorAll('tbody tr').forEach(row=>{
/* ===== 设备概览大弹窗(原型演示:Tab 集中演示各类设备,实际交付按设备类型显示对应概览) ===== */
/* Tab 定义:仅「智能货柜」已实现内容,其余为占位 */
var OV_TABS=[
{key:'scale',name:'餐线营养秤',icon:'⚖️'},
{key:'plate',name:'餐盘柜',icon:'🍽️'},
{key:'smart',name:'智能货柜',icon:'🗄️'},
{key:'raw',name:'毛菜货柜',icon:'🥬'},
@@ -725,6 +767,7 @@ function switchOvTab(key){
/* 按 Tab 渲染内容:占位 / 智能货柜可视化 */
function renderOvTabBody(key){
if(key==='scale')return renderCanteenScale();
if(key==='smart')return renderSmartCabinet();
if(key==='plate')return renderPlateCabinet();
if(key==='clean')return renderStockCabinet('clean');
@@ -741,6 +784,340 @@ function renderOvTabBody(key){
</div>`;
}
/* ===== 表头列筛选(餐线营养秤四个 Tab 复用) ===== */
var SCALE_FILTERS={status:{},face:{},weight:{},log:{}};
/* 每个 Tab 的列定义:key 列标识 / label 表头文字 / field 数据字段 */
var SCALE_COLS={
status:[
{key:'time',label:'日期/时间',field:'time'},
{key:'item',label:'监控项',field:'item'},
{key:'event',label:'事件',field:'event'}
],
face:[
{key:'start',label:'开始时间',field:'start'},
{key:'end',label:'结束时间',field:'end'},
{key:'status',label:'状态',field:'status'},
{key:'weight',label:'重量变化',field:'weight'},
{key:'total',label:'总重量',field:'total'}
],
weight:[
{key:'start',label:'开始时间',field:'start'},
{key:'end',label:'结束时间',field:'end'},
{key:'target',label:'识别对象',field:'target'},
{key:'submit',label:'提交重量',field:'submit'},
{key:'total',label:'总重量',field:'total'}
],
log:[
{key:'time',label:'日期/时间',field:'time'},
{key:'type',label:'报错类型',field:'type'},
{key:'content',label:'报错内容',field:'code'}
]
};
/* 各维度数据声明(实际数据在对应渲染函数上方赋值) */
var SCALE_STATUS_EVENTS=[];
var SCALE_WEIGHT_RECORDS=[];
var SCALE_LOG_RECORDS=[];
/* 生成带筛选图标的表头单元格 */
function scaleTh(colKey,label,tabKey){
var active=SCALE_FILTERS[tabKey]&&SCALE_FILTERS[tabKey][colKey];
return label+'<span class="th-filter'+(active?' th-filter--active':'')+'" data-tab="'+tabKey+'" data-col="'+colKey+'" onclick="toggleThFilter(event)">▾</span>';
}
/* 生成表头(可选追加操作列,操作列不可筛选) */
function scaleThead(tabKey,actionLabel){
var ths=SCALE_COLS[tabKey].map(function(c){return '<th>'+scaleTh(c.key,c.label,tabKey)+'</th>';}).join('');
if(actionLabel)ths+='<th>'+actionLabel+'</th>';
return '<thead><tr>'+ths+'</tr></thead>';
}
/* 取某 Tab 原始数据 */
function getScaleRows(tabKey){
if(tabKey==='status')return SCALE_STATUS_EVENTS||[];
if(tabKey==='face')return SCALE_FACE_RECORDS||[];
if(tabKey==='weight')return SCALE_WEIGHT_RECORDS||[];
if(tabKey==='log')return SCALE_LOG_RECORDS||[];
return [];
}
/* 应用当前筛选,返回过滤后的行 */
function getFilteredScaleRows(tabKey){
var rows=getScaleRows(tabKey);
var f=SCALE_FILTERS[tabKey]||{};
var cols=SCALE_COLS[tabKey]||[];
return rows.filter(function(r){
for(var i=0;i<cols.length;i++){
var v=f[cols[i].key];
if(v && r[cols[i].field]!==v)return false;
}
return true;
});
}
/* 取某列去重值(用于筛选下拉) */
function getScaleColValues(tabKey,colKey){
var col=(SCALE_COLS[tabKey]||[]).filter(function(c){return c.key===colKey;})[0];
if(!col)return [];
var rows=getScaleRows(tabKey);
var seen={},result=[];
rows.forEach(function(r){
var v=r[col.field];
if(v===undefined||v===null||v==='')return;
if(!seen[v]){seen[v]=1;result.push(v);}
});
return result;
}
function toggleThFilter(e){
e.stopPropagation();
closeThFilter();
var icon=e.currentTarget;
var tabKey=icon.dataset.tab,colKey=icon.dataset.col;
var values=getScaleColValues(tabKey,colKey);
var cur=(SCALE_FILTERS[tabKey]&&SCALE_FILTERS[tabKey][colKey])||'';
var pop=document.createElement('div');
pop.className='th-filter-pop';
pop.id='thFilterPop';
var html='<div class="th-filter-pop__item'+(cur===''?' active':'')+'" onclick="pickThFilter(\''+tabKey+'\',\''+colKey+'\',\'\')">全部</div>';
values.forEach(function(v){
html+='<div class="th-filter-pop__item'+(cur===v?' active':'')+'" onclick="pickThFilter(\''+tabKey+'\',\''+colKey+'\',\''+escapeAttr(v)+'\')">'+escapeHtml(v)+'</div>';
});
pop.innerHTML=html;
document.body.appendChild(pop);
var rect=icon.getBoundingClientRect();
pop.style.left=Math.min(rect.left,window.innerWidth-170)+'px';
pop.style.top=(rect.bottom+4)+'px';
document.addEventListener('click',onDocClickForFilter,{once:true});
}
function onDocClickForFilter(e){
if(!e.target.closest('.th-filter-pop'))closeThFilter();
}
function closeThFilter(){
var p=document.getElementById('thFilterPop');
if(p)p.remove();
}
function pickThFilter(tabKey,colKey,val){
if(!SCALE_FILTERS[tabKey])SCALE_FILTERS[tabKey]={};
SCALE_FILTERS[tabKey][colKey]=val;
closeThFilter();
var body=document.getElementById('scaleSubBody');
if(body)body.innerHTML=renderScaleSubBody(SCALE_SUB_TAB);
}
function escapeHtml(s){
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(s){
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
/* ===== 餐线营养秤:四个维度(子页切换) ===== */
var SCALE_SUB_TABS=[
{key:'status',name:'设备状态监控'},
{key:'weight',name:'重量变化数据'},
{key:'face',name:'人脸识别数据'},
{key:'log',name:'运行日志'}
];
var SCALE_SUB_TAB='status';
function renderCanteenScale(){
const tabsHtml=SCALE_SUB_TABS.map(t=>`<div class="scale-sub-tab${t.key===SCALE_SUB_TAB?' active':''}" data-key="${t.key}" onclick="switchScaleSubTab('${t.key}')">${t.name}</div>`).join('');
return `<div class="scale-sub-tabs">${tabsHtml}</div><div id="scaleSubBody">${renderScaleSubBody(SCALE_SUB_TAB)}</div>`;
}
function switchScaleSubTab(key){
SCALE_SUB_TAB=key;
document.querySelectorAll('#ovTabBody .scale-sub-tab').forEach(t=>t.classList.toggle('active',t.dataset.key===key));
const body=document.getElementById('scaleSubBody');
if(body)body.innerHTML=renderScaleSubBody(key);
}
function renderScaleSubBody(key){
if(key==='status')return renderScaleStatus();
if(key==='face')return renderScaleFace();
if(key==='weight')return renderScaleWeight();
if(key==='log')return renderScaleLog();
return '';
}
/* 维度1:设备状态监控 — 状态事件流水 */
SCALE_STATUS_EVENTS=[
{time:'2026-08-25 08:15:32.456',item:'网络连接',event:'重新连接成功'},
{time:'2026-08-25 08:00:00.000',item:'运行状态',event:'开餐'},
{time:'2026-08-25 07:40:12.511',item:'运行状态',event:'加餐'},
{time:'2026-08-25 06:30:00.000',item:'设备开关机',event:'开机'},
{time:'2026-08-25 04:12:33.098',item:'运行状态',event:'换餐'},
{time:'2026-08-24 23:00:00.000',item:'设备开关机',event:'关机'},
{time:'2026-08-24 22:30:18.764',item:'运行状态',event:'闭餐'},
{time:'2026-08-24 21:00:02.130',item:'运行状态',event:'进入待机'}
];
function renderScaleStatus(){
const rows=getFilteredScaleRows('status').map(s=>`<tr><td>${s.time}</td><td>${s.item}</td><td>${s.event}</td></tr>`).join('');
return `<div class="ov-section-title">设备状态监控</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('status')}<tbody>${rows}</tbody></table></div>`;
}
/* 维度2:人脸识别数据 — 开始/结束时间 / 状态 / 用户类型 / 重量变化 + 视频回放 */
var SCALE_FACE_RECORDS=[
{start:'2026-08-25 08:15:23.456',end:'2026-08-25 08:15:58.210',status:'刷脸+离开',userType:'系统用户',total:'486.5g',weight:'+486.5g'},
{start:'2026-08-25 08:14:51.203',end:'2026-08-25 08:15:02.118',status:'刷脸',userType:'系统用户',total:'462.0g',weight:'+462.0g'},
{start:'2026-08-25 08:13:47.889',end:'2026-08-25 08:14:10.540',status:'离开',userType:'系统用户',total:'0.0g',weight:'-445.8g'},
{start:'2026-08-25 08:12:09.310',end:'2026-08-25 08:12:30.775',status:'刷脸',userType:'陌生人',total:'0.0g',weight:'+0.0g'}
];
function renderScaleFace(){
const statusTagMap={
'刷脸':'<span class="tag tag-blue">刷脸</span>',
'离开':'<span class="tag tag-gray">离开</span>',
'刷脸+离开':'<span class="tag tag-purple">刷脸+离开</span>'
};
const rows=getFilteredScaleRows('face').map((f)=>{
const statusTag=statusTagMap[f.status]||'<span class="tag tag-gray">'+escapeHtml(f.status)+'</span>';
const isPos=f.weight.startsWith('+');
const weightHtml=`<span style="color:${isPos?'var(--success)':'var(--danger)'}">${f.weight}</span>`;
return `<tr><td>${f.start}</td><td>${f.end}</td><td>${statusTag}</td><td>${weightHtml}</td><td>${f.total}</td><td><a class="link" onclick="showFaceVideo('${f.start}')">回放视频</a></td></tr>`;
}).join('');
return `<div class="ov-section-title">人脸识别数据</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('face','操作')}<tbody>${rows}</tbody></table></div>`;
}
/* 录像回放弹窗:自动播放 + 快进/后退 + 进度条跳转 */
var FACE_VIDEO={playing:false,current:0,duration:30,timer:null};
function showFaceVideo(start){
const f=SCALE_FACE_RECORDS.find(x=>x.start===start);
if(!f)return;
FACE_VIDEO.current=0;
FACE_VIDEO.duration=30;
FACE_VIDEO.playing=true;
const mask=document.createElement('div');
mask.className='face-video-mask';
mask.id='faceVideoMask';
mask.innerHTML=`<div class="face-video-modal">
<div class="face-video-modal__header">
<div class="face-video-modal__title">人脸识别视频回放 — ${f.start}</div>
<button class="face-video-modal__close" onclick="closeFaceVideo()">&times;</button>
</div>
<div class="face-video-modal__body">
<div class="face-video-player">
<span class="face-video-player__badge">● REC</span>
<div class="face-video-player__scan"></div>
<div class="face-video-player__play" onclick="faceVideoPlayPause()">▶</div>
<div class="face-video-player__meta"><span>CAM-01</span><span>${f.start}</span></div>
</div>
<div class="face-video-controls">
<button class="face-video-btn" onclick="faceVideoSeek(-10)">⏪ 后退10s</button>
<button class="face-video-btn face-video-btn--primary" id="faceVideoPlayBtn" onclick="faceVideoPlayPause()">⏸</button>
<button class="face-video-btn" onclick="faceVideoSeek(10)">前进10s ⏩</button>
<div class="face-video-bar" id="faceVideoBar" onclick="faceVideoSeekTo(event)"><div class="face-video-bar__progress" id="faceVideoProgress"></div></div>
<div class="face-video-time" id="faceVideoTime">00:00 / 00:30</div>
</div>
<div class="face-video-info">
<div class="face-video-info__item"><span class="label">识别时间</span><strong>${f.start}</strong></div>
<div class="face-video-info__item"><span class="label">状态</span><strong>${f.status}</strong></div>
<div class="face-video-info__item"><span class="label">用户类型</span><strong>${f.userType}</strong></div>
<div class="face-video-info__item"><span class="label">重量变化</span><strong>${f.weight}</strong></div>
</div>
</div>
</div>`;
mask.addEventListener('click',e=>{if(e.target===mask)closeFaceVideo();});
document.body.appendChild(mask);
updateFaceVideoUI();
startFaceVideoTimer();
}
function closeFaceVideo(){
clearInterval(FACE_VIDEO.timer);
FACE_VIDEO.playing=false;
const m=document.getElementById('faceVideoMask');
if(!m)return;
m.classList.add('closing');
setTimeout(()=>m.remove(),200);
}
function startFaceVideoTimer(){
clearInterval(FACE_VIDEO.timer);
FACE_VIDEO.timer=setInterval(()=>{
if(!FACE_VIDEO.playing)return;
FACE_VIDEO.current=Math.min(FACE_VIDEO.duration,FACE_VIDEO.current+0.1);
if(FACE_VIDEO.current>=FACE_VIDEO.duration){
FACE_VIDEO.playing=false;
clearInterval(FACE_VIDEO.timer);
}
updateFaceVideoUI();
},100);
}
function faceVideoPlayPause(){
FACE_VIDEO.playing=!FACE_VIDEO.playing;
if(FACE_VIDEO.playing){
if(FACE_VIDEO.current>=FACE_VIDEO.duration)FACE_VIDEO.current=0;
startFaceVideoTimer();
}
updateFaceVideoUI();
}
function faceVideoSeek(delta){
FACE_VIDEO.current=Math.max(0,Math.min(FACE_VIDEO.duration,FACE_VIDEO.current+delta));
updateFaceVideoUI();
}
function faceVideoSeekTo(e){
const bar=document.getElementById('faceVideoBar');
if(!bar)return;
const rect=bar.getBoundingClientRect();
const pct=Math.max(0,Math.min(1,(e.clientX-rect.left)/rect.width));
FACE_VIDEO.current=pct*FACE_VIDEO.duration;
updateFaceVideoUI();
}
function updateFaceVideoUI(){
const progress=document.getElementById('faceVideoProgress');
const time=document.getElementById('faceVideoTime');
const btn=document.getElementById('faceVideoPlayBtn');
const big=document.querySelector('#faceVideoMask .face-video-player__play');
const pct=FACE_VIDEO.current/FACE_VIDEO.duration*100;
if(progress)progress.style.width=pct+'%';
if(time)time.textContent=formatFaceVideoTime(FACE_VIDEO.current)+' / '+formatFaceVideoTime(FACE_VIDEO.duration);
const icon=FACE_VIDEO.playing?'⏸':'▶';
if(btn)btn.textContent=icon;
if(big)big.textContent=icon;
}
function formatFaceVideoTime(t){
const m=Math.floor(t/60);
const s=Math.floor(t%60);
return (m<10?'0'+m:m)+':'+(s<10?'0'+s:s);
}
/* 维度3:重量变化数据 — 开始/结束时间 / 识别对象 / 提交重量 / 总重量 */
SCALE_WEIGHT_RECORDS=[
{start:'2026-08-25 08:15:22.701',end:'2026-08-25 08:15:30.210',target:'EMP0234 · 张伟',submit:'436.5',total:'486.5'},
{start:'2026-08-25 08:14:50.512',end:'2026-08-25 08:14:58.430',target:'无用户',submit:'412.0',total:'462.0'},
{start:'2026-08-25 08:13:46.098',end:'2026-08-25 08:13:55.118',target:'EMP0357 · 王强',submit:'—',total:'501.2'},
{start:'2026-08-25 08:12:08.876',end:'2026-08-25 08:12:18.775',target:'无用户',submit:'0.0',total:'0.0'}
];
function renderScaleWeight(){
const rows=getFilteredScaleRows('weight').map(w=>`<tr><td>${w.start}</td><td>${w.end}</td><td>${w.target}</td><td>${w.submit}</td><td>${w.total}</td></tr>`).join('');
return `<div class="ov-section-title">重量变化数据</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('weight')}<tbody>${rows}</tbody></table></div>`;
}
/* 维度4:运行日志 — 日期/时间 / 报错类型 / 报错内容(报错代码,20字符) / 操作 */
SCALE_LOG_RECORDS=[
{time:'2026-08-25 08:10:12.330',type:'异常报错',code:'E1001_WEIGHT_SENSOR_READ_TIMEOUT_RETRY',detail:'E1001_WEIGHT_SENSOR_READ_TIMEOUT_RETRY\n\n[2026-08-25 08:10:12.330] ERROR WeightSensorModule::read()\n 称重传感器读取超时,连续 3 次无响应\n 设备编号: DEV-00025 传感器通道: CH-02\n 已触发自动重试,若重试失败将上报设备故障\n TraceId: 9f3c1a7b-2e4d-4c8a-b1f0-5d6e7f8a9b0c'},
{time:'2026-08-25 08:02:45.117',type:'性能预警',code:'W2003_RECOG_LATENCY_OVER_THRESHOLD_320MS',detail:'W2003_RECOG_LATENCY_OVER_THRESHOLD_320MS\n\n[2026-08-25 08:02:45.117] WARN RecognitionModule::detect()\n 人脸识别响应延迟 320ms,接近阈值 300ms\n 摄像头: CAM-01 帧率: 25fps\n 建议检查现场光照条件与网络带宽\n TraceId: 4b8e2c31-7a9d-4f5b-9c2e-1a3b4c5d6e7f'},
{time:'2026-08-25 07:58:03.662',type:'性能预警',code:'W2004_MEMORY_USAGE_HIGH_82PCT',detail:'W2004_MEMORY_USAGE_HIGH_82PCT\n\n[2026-08-25 07:58:03.662] WARN SystemMonitor::check()\n 内存占用 82%,接近预警阈值 85%\n 当前进程: nutrition-scale-app\n 建议关注内存泄漏或及时清理缓存\n TraceId: c5d6e7f8-2a3b-4c4d-8e9f-0a1b2c3d4e5f'},
{time:'2026-08-25 07:30:00.000',type:'运行日志',code:'I0000_SELF_CHECK_PASS',detail:'I0000_SELF_CHECK_PASS\n\n[2026-08-25 07:30:00.000] INFO SystemMonitor::selfCheck()\n 设备完成夜间自检,各项指标正常\n 传感器: 正常 摄像头: 正常 网络: 正常\n TraceId: 6f1a2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c'}
];
function truncateCode(code){
if(!code)return '';
return code.length>20?code.slice(0,20)+'…':code;
}
function renderScaleLog(){
const rows=getFilteredScaleRows('log').map(l=>{
const codeHtml=`<span title="${escapeAttr(l.code)}">${escapeHtml(truncateCode(l.code))}</span>`;
return `<tr><td>${l.time}</td><td>${l.type}</td><td>${codeHtml}</td><td><a class="link" onclick="showLogDetail('${escapeAttr(l.code)}')">查看详情</a></td></tr>`;
}).join('');
return `<div class="ov-section-title">运行日志</div>
<div style="overflow-x:auto"><table class="ov-daily-table">${scaleThead('log','操作')}<tbody>${rows}</tbody></table></div>`;
}
function showLogDetail(code){
var rec=SCALE_LOG_RECORDS.filter(function(l){return l.code===code;})[0];
if(!rec)return;
var html='<div style="font-size:13px;color:var(--text-primary)">'
+'<div style="margin-bottom:14px"><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">日期/时间</div><div>'+escapeHtml(rec.time)+'</div></div>'
+'<div style="margin-bottom:14px"><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">报错类型</div><div>'+escapeHtml(rec.type)+'</div></div>'
+'<div><div style="color:var(--text-placeholder);font-size:12px;margin-bottom:4px">报错代码</div><div style="background:#1a1a1a;color:#b8b8b8;font-family:monospace;padding:14px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-size:12px;line-height:1.8;">'+escapeHtml(rec.detail)+'</div></div>'
+'</div>';
DynModal.open('报错详情',html,'<button class="btn btn-primary" onclick="DynModal.close()">关闭</button>');
}
/* ===== 智能货柜:柜子轮廓布局 + 储物状态(参考 prod-proc-cabinet.html 左侧柜子样式) ===== */
/* 格口 Mock:状态 occupied 储物中 / warning 临期预警 / alert 过期预警 / empty 空闲 */
var OV_SLOTS=[
+15
View File
@@ -1,5 +1,20 @@
# 营养用餐管理端 CHANGELOG
## [2026-08-27] feat: 订餐终端设备概览新增「餐线营养秤」Tab
- `din-terminal.html` 订餐终端设备概览:
- 新增「餐线营养秤」Tab,四个维度子页切换(设备状态监控/重量变化数据/人脸识别数据/运行日志)
- 表头新增列筛选(▾ 图标下拉,按列去重取值,四个维度复用)
- 人脸识别数据支持「回放视频」:弹窗含自动播放/快进后退 10s/进度条跳转/识别信息
- 运行日志支持「查看详情」:报错内容超 20 字符截断,详情弹窗展示完整报错日志(含 TraceId)
## [2026-08-26] feat: 净菜订单新增生成历史 + 生成方式列 + 详情就餐人数
- `prod-clean-order.html` 净菜订单:
- 新增「净菜订单生成历史」按钮与弹窗(食谱日期/预定配送时间/餐次/内配调料四筛选 + 9 列表格),由食谱创建订单时自动写入生成历史
- 主列表「订单类型」前新增「生成方式」列(手动/自动),预置自动订单 CO-009 示例
- 详情抽屉订单明细:仅自动生成的订单展示「就餐人数」列
## [2026-08-25] feat: 餐线设置新增用户范围必填复选 + 食谱订单餐品明细表份数调整
- `din-canteen-line.html` 餐线管理:
@@ -0,0 +1,159 @@
# 营养用餐模块 - 角色泳道图 & 问题汇总
> 横轴:角色(平台 → 加工商 → 场所 → 用户)
> 纵轴:场景流程
> 标注:每个场景下方附现有问题
---
## 一、角色场景泳道图
```mermaid
flowchart TD
subgraph 平台["🏛️ 平台(Platform"]
direction TB
subgraph 准备["阶段一:平台准备"]
S1["<b>场景1:食物成分录入</b><br/>──────────────<br/>营养用餐 → 食物成分<br/>→ 分类管理:新增多级分类<br/>→ 点击新增食物<br/>→ 录入食物成分信息<br/>→ 保存"]
S2["<b>场景2:餐品总库</b><br/>──────────────<br/>营养用餐 → 餐品总库<br/>→ 新增餐品分类<br/>→ 新增餐品<br/>→ 保存"]
end
subgraph 场所管理["阶段二:场所管理"]
S3["<b>场景3:新增场所</b><br/>──────────────<br/>营养用餐 → 场所管理<br/>→ 新增场所<br/>→ 录入场所信息<br/>→ 保存 → 场所创建完成<br/>→ 主账号发放"]
S4["<b>场景4:餐线管理</b><br/>──────────────<br/>场所管理 → 选择场所<br/>→ 餐线管理<br/>→ 绑定设备"]
end
准备 --> 场所管理
end
subgraph 加工商["🏭 加工商(Processor"]
P_EMPTY["(待补充)"]
end
subgraph 场所["📍 场所(Venue"]
V_EMPTY["(待补充)"]
end
subgraph 用户["👤 用户(User"]
U_EMPTY["(待补充)"]
end
平台 --> 加工商 --> 场所 --> 用户
%% 平台准备 - 食物成分问题
S1 -.->|"⚠️"| Q1["⑥ 缺少「食物功效」模块"]
S1 -.->|"⚠️"| Q2["⑦ 味道档案应改为<br/>酸甜苦辣咸下拉菜单"]
S1 -.->|"⚠️"| Q3["⑧ 分类应内置<br/>中国食物成分表标准分类"]
S1 -.->|"⚠️"| Q4["⑨ 口感和色泽字段不需要"]
S1 -.->|"⚠️"| Q5["⑩ 宏量营养素应为必填项"]
S1 -.->|"⚠️"| Q6["⑪ 食物类目启禁用<br/>应展示在列表中"]
S1 -.->|"⚠️"| Q7["⑫ 食物缺少启禁用状态"]
%% 平台准备 - 餐品总库问题
S2 -.->|"⚠️"| Q8["⑬ 餐品分类无法维护<br/>(增删改查)"]
S2 -.->|"⚠️"| Q9["⑭ 餐品分类无法启禁用"]
S2 -.->|"⚠️"| Q10["⑮ 基本档案中适用餐次、<br/>过敏原建议删除"]
S2 -.->|"⚠️"| Q11["⑯ 烹饪工艺/味道档案/<br/>口感档案/色泽档案<br/>建议删除"]
S2 -.->|"⚠️"| Q12["⑰ 营养指标模块<br/>应按2.0版本做"]
%% 场所管理 - 新增场所问题
S3 -.->|"⚠️"| Q13["① 负责人和负责人电话<br/>应为必填项"]
S3 -.->|"⚠️"| Q14["② 「是否使用三减终端」<br/>含义不明确"]
S3 -.->|"⚠️"| Q15["③ 详细地址应为必填项"]
S3 -.->|"⚠️"| Q16["④ 场所主账号应<br/>发送到负责人手机"]
S3 -.->|"⚠️"| Q17["⑤ 场所管理缺少<br/>启禁用按钮"]
%% 场所管理 - 餐线管理问题
S4 -.->|"⚠️"| Q18["⑥ 绑定设备数量不反显"]
S4 -.->|"⚠️"| Q19["⑦ 期望有默认餐线"]
```
---
## 二、泳道图(简化文字版)
```
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ 平台 │ 加工商 │ 场所 │ 用户 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ │ │ │ │
│ 【平台准备】 │ │ │ │
│ │ │ │ │
│ 1.食物成分 │ │ │ │
│ 分类管理 │ │ │ │
│ 新增食物 │ │ │ │
│ │ │ │ │
│ 2.餐品总库 │ │ │ │
│ 餐品分类 │ │ │ │
│ 新增餐品 │ │ │ │
│ │ │ │ │
│ 【场所管理】 │ │ │ │
│ │ │ │ │
│ 3.新增场所 │ │ │ │
│ 录入信息 │ │ │ │
│ 主账号发放 │ │ │ │
│ │ │ │ │
│ 4.餐线管理 │ │ │ │
│ 绑定设备 │ │ │ │
│ │ │ │ │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ │ (待补充) │ (待补充) │ (待补充) │
└──────────────┴──────────────┴──────────────┴──────────────┘
```
---
## 三、问题汇总表
### 平台准备 → 食物成分(#⑥ ~ #⑫)
| # | 问题 | 期望 |
|---|------|------|
| ⑥ | 缺少「食物功效」模块 | 新增模块,后期用于匹配模型自动关联用户调理方案 |
| ⑦ | 味道档案含主观填写字段(口味描述、辅味型、味道强度、饮食禁忌等) | 改为**酸甜苦辣咸**下拉菜单,去掉其余主观字段 |
| ⑧ | 分类需手动新建 | 内置**中国食物成分表**标准分类 |
| ⑨ | 有「口感」和「色泽」字段 | 去掉,不需要 |
| ⑩ | 宏量营养素非必填 | 改为**必填项** |
| ⑪ | 食物类目启禁用在编辑页内,操作不便 | 启禁用展示在**列表中**;禁用类目时,关联餐品**全部下架** |
| ⑫ | 食物缺少启禁用状态 | 新增启禁用,逻辑同类目:禁用食物时关联餐品全部下架 |
### 平台准备 → 餐品总库(#⑬ ~ #⑰)
| # | 问题 | 期望 |
|---|------|------|
| ⑬ | 餐品分类无法维护(增删改查) | 支持餐品分类的增删改查 |
| ⑭ | 餐品分类无法启禁用 | 新增启禁用功能 |
| ⑮ | 基本档案中「适用餐次」「过敏原」多余 | 删除适用餐次、过敏原字段 |
| ⑯ | 烹饪工艺、味道档案、口感档案、色泽档案模块多余 | 全部删除 |
| ⑰ | 营养指标模块不符合 2.0 版本规范 | 按 2.0 版本重新设计 |
### 场所管理 → 新增场所(#① ~ #⑤)
| # | 问题 | 期望 |
|---|------|------|
| ① | 负责人和负责人电话非必填 | 改为**必填项** |
| ② | 「是否使用三减终端」含义不明确 | 加字段说明或提示文案 |
| ③ | 详细地址非必填 | 改为**必填项** |
| ④ | 场所主账号发放逻辑不明确 | 创建场所后主账号**发送到负责人手机** |
| ⑤ | 场所管理缺少启禁用按钮 | 新增启禁用按钮 |
### 场所管理 → 餐线管理(#⑥ ~ #⑦)
| # | 问题 | 期望 |
|---|------|------|
| ⑥ | 绑定设备后数量不反显 | 绑定后实时显示已绑设备数量 |
| ⑦ | 没有默认餐线 | 创建场所后自动生成一条默认餐线 |
---
## 四、汇总统计
| 角色 | 阶段 | 场景数 | 问题数 | 状态 |
|------|------|--------|--------|------|
| 平台 | 平台准备 | 2(食物成分、餐品总库) | 12 | ✅ 已梳理 |
| 平台 | 场所管理 | 2(新增场所、餐线管理) | 7 | ✅ 已梳理 |
| **平台小计** | — | **4** | **19** | — |
| 加工商 | — | 0 | 0 | ⬜ 待补充 |
| 场所 | — | 0 | 0 | ⬜ 待补充 |
| 用户 | — | 0 | 0 | ⬜ 待补充 |
| **总计** | — | **4** | **19** | — |
+172 -7
View File
@@ -268,17 +268,18 @@ tr.detail-row td.td-actions{background:#FAFBFC;box-shadow:-4px 0 8px rgba(0,0,0,
<div class="action-bar">
<button class="btn btn-primary" onclick="openModal('modal-order')"> 新建订单</button>
<button class="btn" style="background:#722ED1;color:#fff;" onclick="openModal('modal-recipe-order')">📋 由食谱快速创建订单</button>
<button class="btn" style="background:#13C2C2;color:#fff;" onclick="openModal('modal-gen-history')">📜 净菜订单生成历史</button>
</div>
<div class="table-card">
<div class="table-card__header"><div class="table-card__title">净菜订单</div></div>
<div class="table-card__content">
<table id="orderTable">
<thead><tr><th style="width:36px;"></th><th>订单号</th><th>订单类型</th><th>净菜类型</th><th>菜品种类</th><th>总份数</th><th>总计量(kg)</th><th>订单金额</th><th>订单状态</th><th>申领时间</th><th>预定配送时间</th><th>创建人</th><th>物流信息</th><th>操作</th></tr></thead>
<thead><tr><th style="width:36px;"></th><th>订单号</th><th>生成方式</th><th>订单类型</th><th>净菜类型</th><th>菜品种类</th><th>总份数</th><th>总计量(kg)</th><th>订单金额</th><th>订单状态</th><th>申领时间</th><th>预定配送时间</th><th>创建人</th><th>物流信息</th><th>操作</th></tr></thead>
<tbody id="orderTableBody"></tbody>
</table>
</div>
<div class="pagination">
<span>显示 8 条,共 8</span>
<span>显示 9 条,共 9</span>
<button class="page-btn"></button><button class="page-btn active">1</button><button class="page-btn"></button>
</div>
</div>
@@ -397,6 +398,34 @@ tr.detail-row td.td-actions{background:#FAFBFC;box-shadow:-4px 0 8px rgba(0,0,0,
</div>
</div>
<!-- 弹窗:净菜订单生成历史 -->
<div class="modal-mask" id="modal-gen-history">
<div class="modal" style="width:1080px;">
<div class="modal__header">
<div class="modal__title">净菜订单生成历史</div>
<button class="modal__close" onclick="closeModal('modal-gen-history')"></button>
</div>
<div class="modal__body">
<!-- 筛选 + 订单切换 -->
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
<div class="filter-item"><label>食谱日期</label><input class="filter-input" type="date" id="ghDate" onchange="renderGenHistory()" style="min-width:150px;"></div>
<button class="btn btn-default" onclick="resetGenHistoryFilter()">重置</button>
</div>
<!-- 生成记录明细(订单信息与餐品明细合并) -->
<div style="overflow-x:auto;">
<table class="ing-table">
<thead><tr><th>食谱日期</th><th>餐次</th><th>餐品名称</th><th>就餐人数</th><th>内配料(是否)</th><th>打包份数</th><th>预定配送时间</th><th>开始生成时间</th><th>生成成功时间</th></tr></thead>
<tbody id="genHistoryInfoBody"></tbody>
</table>
</div>
<div id="genHistoryEmpty" style="display:none;text-align:center;color:var(--text-placeholder);padding:40px 0;">暂无符合条件的生成记录</div>
</div>
<div class="modal__footer">
<button class="btn btn-default" onclick="closeModal('modal-gen-history')">关闭</button>
</div>
</div>
</div>
<script>
/* ========== 弹窗开关 ========== */
function openModal(id){
@@ -539,6 +568,15 @@ var orders=[
{id:'L16',ncType:'cp',foodName:'蒜蓉菠菜',spec:'标准份(400g',qty:15,weight:6,price:10,subtotal:60,status:'已完成',needSeasoning:false},
{id:'L17',ncType:'cp',foodName:'炒胡萝卜',spec:'标准份(300g',qty:20,weight:6,price:8,subtotal:48,status:'已完成',needSeasoning:true}
]
},
{
orderNo:'CO-009',orderType:'内供',ncType:'cp',genType:'auto',
claimTime:'2026-05-20 09:30',deliverTime:'2026-05-21 10:00',creator:'系统',sourceOrderNo:null,
logistics:null,
lines:[
{id:'L18',ncType:'cp',foodName:'宫保鸡丁',spec:'标准份(500g',qty:120,dinerCount:120,weight:60,price:12,subtotal:720,status:'生成中',needSeasoning:true},
{id:'L19',ncType:'cp',foodName:'清蒸鲈鱼',spec:'标准份(600g',qty:100,dinerCount:100,weight:60,price:15,subtotal:900,status:'生成中',needSeasoning:false}
]
}
];
@@ -741,6 +779,101 @@ function orderTypeTag(type){
return '<span class="tag tag-orange">🛒 外售</span>';
}
/* 生成方式徽标:自动(由食谱生成) / 手动(人工新建) */
function genTypeTag(type){
return type==='auto'
?'<span class="tag tag-cyan">⚡ 自动</span>'
:'<span class="tag tag-gray">✍ 手动</span>';
}
/* ========== 净菜订单生成历史 ========== */
var genHistory=[
{
orderNo:'CO-009',recipeDate:'2026-05-20',deliverTime:'2026-05-21 10:00',startTime:'2026-05-20 09:30:00',successTime:'2026-05-20 09:31:12',
details:[
{meal:'午餐',foodName:'宫保鸡丁',rawWeight:60,cookedWeight:51,dinerCount:120,needSeasoning:true,servings:120,avgWeight:0.5},
{meal:'午餐',foodName:'清蒸鲈鱼',rawWeight:60,cookedWeight:52.8,dinerCount:100,needSeasoning:false,servings:100,avgWeight:0.6}
]
},
{
orderNo:'CO-010',recipeDate:'2026-05-20',deliverTime:'2026-05-21 06:30',startTime:'2026-05-20 05:30:00',successTime:'2026-05-20 05:31:03',
details:[
{meal:'早餐',foodName:'小米粥',rawWeight:24,cookedWeight:20.4,dinerCount:80,needSeasoning:false,servings:80,avgWeight:0.3},
{meal:'早餐',foodName:'煮鸡蛋',rawWeight:4.8,cookedWeight:4.1,dinerCount:80,needSeasoning:false,servings:80,avgWeight:0.06}
]
},
{
orderNo:'CO-011',recipeDate:'2026-05-20',deliverTime:'2026-05-21 16:00',startTime:'2026-05-20 14:30:00',successTime:'2026-05-20 14:31:11',
details:[
{meal:'晚餐',foodName:'红烧肉',rawWeight:50,cookedWeight:42.5,dinerCount:100,needSeasoning:true,servings:100,avgWeight:0.5},
{meal:'晚餐',foodName:'清蒸鲈鱼',rawWeight:60,cookedWeight:52.8,dinerCount:100,needSeasoning:false,servings:100,avgWeight:0.6}
]
},
{
orderNo:'CO-012',recipeDate:'2026-05-19',deliverTime:'2026-05-20 16:00',startTime:'2026-05-19 14:30:00',successTime:'2026-05-19 14:31:15',
details:[
{meal:'晚餐',foodName:'麻婆豆腐',rawWeight:36,cookedWeight:30.6,dinerCount:90,needSeasoning:true,servings:90,avgWeight:0.4}
]
},
{
orderNo:'CO-013',recipeDate:'2026-05-20',deliverTime:'2026-05-21 14:00',startTime:'2026-05-20 13:30:00',successTime:'2026-05-20 13:31:02',
details:[
{meal:'加餐',foodName:'水果拼盘',rawWeight:18,cookedWeight:18,dinerCount:60,needSeasoning:false,servings:60,avgWeight:0.3}
]
}
];
function pad2(n){return String(n).padStart(2,'0');}
function formatDateTime(d){
return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate())+' '+pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds());
}
function genSeasoningTag(needSeasoning){
return needSeasoning
?'<span class="tag tag-blue">✓ 是</span>'
:'<span class="tag tag-gray">✗ 否</span>';
}
var genHistoryFiltered=[];
function renderGenHistory(){
var date=document.getElementById('ghDate').value;
genHistoryFiltered=genHistory.filter(function(r){
if(date&&r.recipeDate!==date)return false;
return true;
});
var empty=document.getElementById('genHistoryEmpty');
empty.style.display=genHistoryFiltered.length?'none':'block';
/* 默认展示最近一次(数组首位) */
if(genHistoryFiltered.length>0){
renderGenHistoryOrder(genHistoryFiltered[0].orderNo);
}else{
document.getElementById('genHistoryInfoBody').innerHTML='';
}
}
function renderGenHistoryOrder(orderNo){
var rec=genHistoryFiltered.find(function(r){return r.orderNo===orderNo;});
if(!rec)rec=genHistory.find(function(r){return r.orderNo===orderNo;});
if(!rec)return;
/* 订单信息与餐品明细合并为一张表:每条明细一行,重复订单级字段 */
var infoBody=document.getElementById('genHistoryInfoBody');
var html='';
rec.details.forEach(function(d){
html+='<tr>'
+'<td>'+rec.recipeDate+'</td>'
+'<td>'+d.meal+'</td>'
+'<td style="font-weight:500;">'+d.foodName+'</td>'
+'<td>'+d.dinerCount+'</td>'
+'<td>'+genSeasoningTag(d.needSeasoning)+'</td>'
+'<td>'+d.servings+'</td>'
+'<td>'+rec.deliverTime+'</td>'
+'<td>'+rec.startTime+'</td>'
+'<td>'+rec.successTime+'</td>'
+'</tr>';
});
infoBody.innerHTML=html;
}
function resetGenHistoryFilter(){
document.getElementById('ghDate').value='';
renderGenHistory();
}
/* ========== 渲染订单表格 ========== */
function renderOrderTable(){
var tbody=document.getElementById('orderTableBody');
@@ -752,6 +885,7 @@ function renderOrderTable(){
html+='<tr class="master-row" data-order-idx="'+oi+'" onclick="toggleExpand(this,'+oi+')">';
html+='<td><span class="expand-arrow" id="arrow'+oi+'">▶</span></td>';
html+='<td><b>'+order.orderNo+'</b></td>';
html+='<td>'+genTypeTag(order.genType)+'</td>';
html+='<td>'+orderTypeTag(order.orderType)+'</td>';
html+='<td>'+ncTypeTag(order.ncType)+'</td>';
html+='<td>'+order.lines.length+'</td>';
@@ -781,9 +915,9 @@ function renderOrderTable(){
/* 子表头 */
if(order.lines.length>0){
if(order.ncType==='nc'){
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td>食材</td><td>净菜名称</td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td>食材</td><td>净菜名称</td><td></td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td></tr>';
}else{
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td colspan="2">菜品/食材</td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
html+='<tr class="detail-header" id="dhead-'+oi+'" data-order-idx="'+oi+'" style="display:none;"><td></td><td colspan="2">菜品/食材</td><td></td><td>包装规格</td><td>份数</td><td>计量(kg)</td><td>单价(元/kg)</td><td>小计金额</td><td>内配调料</td><td></td><td></td><td></td><td></td><td></td></tr>';
}
}
/* 明细行 */
@@ -795,6 +929,7 @@ function renderOrderTable(){
}else{
html+='<td colspan="2" style="font-weight:500;">'+line.foodName+'</td>';
}
html+='<td></td>';
html+='<td>'+line.spec+'</td>';
html+='<td>'+line.qty+'</td>';
html+='<td>'+line.weight+'</td>';
@@ -1087,7 +1222,7 @@ function submitCreateOrder(){
var orderNo='CO-'+String(orders.length+1).padStart(3,'0');
var deliverTime=document.getElementById('orderDeliveryTime').value||'—';
var newOrder={
orderNo:orderNo,orderType:'内供',
orderNo:orderNo,orderType:'内供',genType:'manual',
claimTime:new Date().toISOString().slice(0,16).replace('T',' '),
deliverTime:deliverTime,creator:creator,
ncType:newOrderNcType,
@@ -1194,12 +1329,16 @@ function showDetail(orderNo){
/* 订单明细列表 */
html+='<div class="drawer-section"><div class="drawer-section__title">订单明细('+order.lines.length+' 道菜品)</div>';
var isNcOrder=order.ncType==='nc';
var isAutoOrder=order.genType==='auto';
html+='<table class="ing-table"><thead><tr><th>净菜类型</th>';
if(isNcOrder){
html+='<th>食材</th><th>净菜名称</th>';
}else{
html+='<th>菜品/食材</th>';
}
if(isAutoOrder){
html+='<th>就餐人数</th>';
}
html+='<th>包装规格</th><th>份数</th><th>计量(kg)</th><th>单价(元/kg)</th><th>小计金额</th><th>内配调料</th><th>行状态</th></tr></thead><tbody>';
order.lines.forEach(function(line){
var tagCls={'已完成':'tag-green','配送中':'tag-blue','待配送':'tag-orange','备货中':'tag-gray'};
@@ -1209,12 +1348,15 @@ function showDetail(orderNo){
}else{
html+='<td'+(isNcOrder?' colspan="2"':'')+'><b>'+line.foodName+'</b></td>';
}
if(isAutoOrder){
html+='<td>'+(line.dinerCount||'—')+'</td>';
}
html+='<td>'+line.spec+'</td><td>'+line.qty+'</td><td>'+line.weight+'</td><td>'+(line.price||0)+'</td><td>'+formatMoney(line.subtotal||0)+'</td><td>'+seasoningTag(line)+'</td><td><span class="tag '+tagCls[line.status]+'">'+line.status+'</span></td></tr>';
/* 餐品净菜包显示食材明细 */
if(line.ncType==='cp'){
var ings=dishIngredients[line.foodName]||[];
if(ings.length>0){
var colSpan=isNcOrder?10:9;
var colSpan=isNcOrder?10:(isAutoOrder?10:9);
html+='<tr><td colspan="'+colSpan+'" style="padding:4px 10px;background:#fafafa;"><span style="font-size:11px;color:var(--text-placeholder);">食材构成:</span>';
ings.forEach(function(ing,i){
if(i>0)html+=', ';
@@ -1446,6 +1588,11 @@ function submitRecipeOrder(){
if(missing.length>0){alert('以下餐次尚未填写就餐人数:'+missing.join('、'));return;}
/* 由食谱创建一个包含所选餐次全部菜品的订单,订单初始状态为「生成中」 */
var daily=getDailyMenu(recipeDate);
/* 记录本次生成历史:开始时间与模拟成功时间(1~2 分钟后) */
var genStart=new Date();
var genStartStr=formatDateTime(genStart);
var genSuccessStr=formatDateTime(new Date(genStart.getTime()+(60+Math.round(Math.random()*60))*1000));
var genDetails=[];
var dishMap={};
mealIndices.forEach(function(mi){
var input=document.querySelector('#dailyMenuBody .meal-diner-input[data-meal-idx="'+mi+'"]');
@@ -1457,9 +1604,25 @@ function submitRecipeOrder(){
/* 同菜品跨餐次:累加人数(按总人数核算) */
dishMap[dishName].dinerCount+=dinerCount;
}
/* 收集生成历史明细 */
var ghSpec=getDishSpecForRecipe(dishName);
var ghBaseGram=ghSpec.baseGram||500;
var ghServings=ghBaseGram>4000?Math.ceil(ghBaseGram*dinerCount/4000):dinerCount;
var ghRawWeight=parseFloat((ghBaseGram*dinerCount/1000).toFixed(2));
genDetails.push({
meal:MEAL_NAMES[mi],foodName:dishName,
rawWeight:ghRawWeight,cookedWeight:parseFloat((ghRawWeight*0.85).toFixed(2)),
dinerCount:dinerCount,needSeasoning:recipeSeasoningMap[dishName]===true,
servings:ghServings,avgWeight:parseFloat((ghRawWeight/ghServings).toFixed(2))
});
});
});
var orderNo='CO-'+String(orders.length+1).padStart(3,'0');
/* 写入净菜订单生成历史(订单粒度) */
genHistory.unshift({
orderNo:orderNo,recipeDate:recipeDate,deliverTime:deliveryTime.replace('T',' '),
startTime:genStartStr,successTime:genSuccessStr,details:genDetails
});
var newLines=[];
var di=0;
for(var name in dishMap){
@@ -1475,6 +1638,7 @@ function submitRecipeOrder(){
spec:baseGram+'g/'+unit,
portion:sp.portion,baseGram:baseGram,unit:unit,
qty:servings,
dinerCount:item.dinerCount,
weight:parseFloat((baseGram*item.dinerCount/1000).toFixed(2)),
status:'生成中',
needSeasoning:recipeSeasoningMap[name]===true
@@ -1482,7 +1646,7 @@ function submitRecipeOrder(){
di++;
}
orders.unshift({
orderNo:orderNo,orderType:'内供',ncType:'cp',
orderNo:orderNo,orderType:'内供',ncType:'cp',genType:'auto',
claimTime:new Date().toISOString().slice(0,16).replace('T',' '),
deliverTime:deliveryTime.replace('T',' '),creator:'系统',
sourceOrderNo:null,
@@ -1509,6 +1673,7 @@ function sanitizeIntegerInput(el){el.value=el.value.replace(/[^\d]/g,'');}
recipeServingMap={};
onRecipeDateChange();
}
if(id==='modal-gen-history'){document.getElementById('ghDate').value='';renderGenHistory();}
document.getElementById(id).classList.add('show');
};
})();