feat: 订餐终端设备概览新增餐线营养秤Tab + 净菜订单新增生成历史/生成方式列/就餐人数
- din-terminal.html: 设备概览新增「餐线营养秤」Tab(四维度子页切换),表头列筛选 + 人脸识别视频回放弹窗 + 运行日志报错详情 - prod-clean-order.html: 新增「净菜订单生成历史」按钮与弹窗、主列表「生成方式」列、详情抽屉自动订单「就餐人数」列 - 新增文档: 异常场景素材-秤体视角 / 加工厂硬件流程说明 / 角色场景流程图 / 营养秤多维状态矩阵
This commit is contained in:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escapeAttr(s){
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
/* ===== 餐线营养秤:四个维度(子页切换) ===== */
|
||||
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()">×</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=[
|
||||
|
||||
@@ -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** | — |
|
||||
@@ -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');
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user