diff --git a/design/prototype/screens/datewheel.js b/design/prototype/screens/datewheel.js
index 1ec91a8..1456223 100644
--- a/design/prototype/screens/datewheel.js
+++ b/design/prototype/screens/datewheel.js
@@ -1,47 +1,127 @@
-// 滚轮日期选择器 —— 年月日,单据日期/生产日期/明细共用(单一真源)
-// 用法:在任意 .datefield 元素上点击(或聚焦后按 Enter/空格)打开滚轮;
-// 元素持有 data-val="YYYY-MM-DD";选定后写回 data-val + .dval 文本,并派发 'datechange' 事件(detail.value)。
-// 三列滚轮(年/月/日)滚动吸附,年/月变化自动重算当月天数。
+// 滚轮日期选择器 —— 扁平统一日期组件(单一真源;镜像 client/lib/widgets/wheel_date_picker.dart,2026-07-04 拍板)
+// 单日期:任意 .datefield 元素点击(或聚焦后按 Enter/空格)打开锚定面板 = 可输入框 + 三列滚轮 + 底栏(今天/确定);
+// 元素持有 data-val="YYYY-MM-DD";确定写回 data-val + .dval 文本,并派发 'datechange' 事件(detail.value)。
+// 范围:DateWheel.openRange(anchor,{from,to,onCommit}) 起始|结束双窗格并排锚定下拉 + 共用底栏(范围预览/确定),
+// 起止倒置自动交换;DateWheel.mountRange(host,{from,to,onCommit}) 把同一套窗格纵排挂进移动底部 sheet。
+// 窗格(镜像 _DatePane):输入框与滚轮同宽(248)、文字水平垂直居中(mono);键入 2024 / 2024-5-12 / 20240512
+// 回车/失焦归一 yyyy-MM-dd 并吸附滚轮(词法对齐 date_util.dart normalizeYmd),滚轮滚动实时回写输入框。
(function(){
const ITEM=36;
const now=new Date();
const YEARS=[]; for(let y=now.getFullYear()-15;y<=now.getFullYear()+2;y++) YEARS.push(y);
- let pop, yCol, mCol, dCol, target=null, building=false;
-
const pad=n=>String(n).padStart(2,'0');
const dim=(y,m)=>new Date(y,m,0).getDate(); // m: 1-12
+ const fmt=(y,m,d)=>y+'-'+pad(m)+'-'+pad(d);
+ const today=()=>fmt(now.getFullYear(),now.getMonth()+1,now.getDate());
- function ensure(){
- if(pop) return;
- pop=document.createElement('div'); pop.className='wheel-pop';
- pop.innerHTML='
'
- +'';
- document.body.appendChild(pop);
- yCol=pop.querySelector('[data-k="y"]'); mCol=pop.querySelector('[data-k="m"]'); dCol=pop.querySelector('[data-k="d"]');
- [yCol,mCol,dCol].forEach(col=>{
- let t; col.addEventListener('scroll',()=>{ if(building) return; markOn(col); clearTimeout(t); t=setTimeout(()=>{ if(col.dataset.k!=='d') rebuildDays(); },110); });
- });
- pop.querySelector('.wheel-today').onclick=()=>setSel(now.getFullYear(),now.getMonth()+1,now.getDate());
- pop.querySelector('.wheel-ok').onclick=commit;
- document.addEventListener('mousedown',e=>{ if(pop.classList.contains('show') && !pop.contains(e.target) && e.target!==target && !(target&&target.contains(e.target))) close(); });
- window.addEventListener('resize',close);
+ // 键入词法归一(对齐 normalizeYmd):纯数字 yyyymmdd/yyyymm/yyyy 或任意分隔串;缺月/日补 01,超界日 clamp。
+ function normalize(raw){
+ const s=String(raw||'').trim(); if(!s) return null;
+ let y,m,d;
+ if(/^[0-9]+$/.test(s)){
+ if(s.length>=8){ y=+s.slice(0,4); m=+s.slice(4,6); d=+s.slice(6,8); }
+ else if(s.length===6){ y=+s.slice(0,4); m=+s.slice(4,6); }
+ else y=+s;
+ } else {
+ const p=s.split(/[^0-9]+/).filter(Boolean);
+ if(p.length)y=+p[0]; if(p.length>=2)m=+p[1]; if(p.length>=3)d=+p[2];
+ }
+ if(!y||y<1900||y>2200) return null;
+ m=m||1; d=d||1;
+ if(m<1||m>12) return null;
+ const n=dim(y,m); if(d<1)d=1; if(d>n)d=n;
+ return fmt(y,m,d);
}
- const items=col=>col.querySelectorAll('.wheel-item');
- const idxOf=col=>{ const n=items(col).length; return Math.max(0,Math.min(n-1,Math.round(col.scrollTop/ITEM))); };
- function markOn(col){ const idx=idxOf(col); items(col).forEach((el,i)=>el.classList.toggle('on',i===idx)); }
- function fill(col,arr,selIdx){ building=true; col.innerHTML=arr.map(t=>`
${t}
`).join(''); col.scrollTop=selIdx*ITEM; markOn(col); requestAnimationFrame(()=>{ building=false; }); }
- function rebuildDays(){ const y=YEARS[idxOf(yCol)], m=idxOf(mCol)+1, n=dim(y,m); const cur=Math.min(idxOf(dCol),n-1); fill(dCol,Array.from({length:n},(_,i)=>pad(i+1)),cur); }
- function setSel(y,m,d){ const yi=Math.max(0,YEARS.indexOf(y)); fill(yCol,YEARS.map(String),yi); fill(mCol,Array.from({length:12},(_,i)=>pad(i+1)),m-1); const n=dim(y,m); fill(dCol,Array.from({length:n},(_,i)=>pad(i+1)),Math.min(d-1,n-1)); }
- function position(el){ const r=el.getBoundingClientRect(); const w=248; pop.style.width=w+'px'; pop.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px'; pop.style.top=(r.bottom+4)+'px'; const ph=pop.getBoundingClientRect(); if(r.bottom+ph.height+8>window.innerHeight && r.top-ph.height-4>0) pop.style.top=(r.top-ph.height-4)+'px'; }
- function open(el){ ensure(); target=el; let y,m,d; const v=el.dataset.val; if(v && /^\d{4}-\d{2}-\d{2}$/.test(v)){ [y,m,d]=v.split('-').map(Number); } else { y=now.getFullYear(); m=now.getMonth()+1; d=now.getDate(); }
- pop.classList.add('show'); position(el); // 先显示获得布局,scrollTop 才能生效
- setSel(y,m,d); }
- function close(){ if(pop) pop.classList.remove('show'); target=null; }
- function commit(){ if(!target) return; const y=YEARS[idxOf(yCol)], m=idxOf(mCol)+1, d=idxOf(dCol)+1; const val=y+'-'+pad(m)+'-'+pad(d); target.dataset.val=val; const dv=target.querySelector('.dval'); if(dv){ dv.textContent=val; dv.classList.remove('ph'); } target.classList.remove('error'); target.dispatchEvent(new CustomEvent('datechange',{bubbles:true,detail:{value:val}})); close(); }
+ // ---- 窗格(可输入框 + 三列滚轮,输入 ↔ 滚轮双向联动) ----
+ function makePane(title){
+ const root=document.createElement('div'); root.className='wheel-pane';
+ root.innerHTML=(title?`
${title}
`:'')
+ +'
'
+ +'
';
+ const input=root.querySelector('.wheel-input');
+ const yCol=root.querySelector('[data-k="y"]'), mCol=root.querySelector('[data-k="m"]'), dCol=root.querySelector('[data-k="d"]');
+ let building=false;
+ const items=col=>col.querySelectorAll('.wheel-item');
+ const idxOf=col=>{ const n=items(col).length; return Math.max(0,Math.min(n-1,Math.round(col.scrollTop/ITEM))); };
+ const markOn=col=>{ const idx=idxOf(col); items(col).forEach((el,i)=>el.classList.toggle('on',i===idx)); };
+ const fill=(col,arr,selIdx)=>{ building=true; col.innerHTML=arr.map(t=>`
${t}
`).join(''); col.scrollTop=selIdx*ITEM; markOn(col); requestAnimationFrame(()=>{ building=false; }); };
+ const get=()=>fmt(YEARS[idxOf(yCol)],idxOf(mCol)+1,idxOf(dCol)+1);
+ function rebuildDays(){ const y=YEARS[idxOf(yCol)], m=idxOf(mCol)+1, n=dim(y,m); const cur=Math.min(idxOf(dCol),n-1); fill(dCol,Array.from({length:n},(_,i)=>pad(i+1)),cur); }
+ // 吸附滚轮到 val(不动输入框,供键入联动);set 额外回写输入框
+ function snap(val){ let [y,m,d]=(normalize(val)||today()).split('-').map(Number);
+ const yi=Math.max(0,YEARS.indexOf(y)); fill(yCol,YEARS.map(String),yi); fill(mCol,Array.from({length:12},(_,i)=>pad(i+1)),m-1);
+ const n=dim(YEARS[yi],m); fill(dCol,Array.from({length:n},(_,i)=>pad(i+1)),Math.min(d-1,n-1)); }
+ function set(val){ snap(val); input.value=get(); }
+ [yCol,mCol,dCol].forEach(col=>{ let t; col.addEventListener('scroll',()=>{ if(building) return; markOn(col);
+ input.value=get(); if(api.onChange)api.onChange(get());
+ clearTimeout(t); t=setTimeout(()=>{ if(col.dataset.k!=='d'){ rebuildDays(); input.value=get(); } },110); }); });
+ // 键入完整日期(≥8 位)即联动滚轮;回车/失焦归一显示(2024 → 2024-01-01),无效则回显当前值
+ input.addEventListener('input',()=>{ const n=normalize(input.value); if(n && input.value.trim().length>=8){ snap(n); if(api.onChange)api.onChange(n); } });
+ input.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); const n=normalize(input.value); if(n){ set(n); if(api.onChange)api.onChange(n); } } });
+ input.addEventListener('blur',()=>{ const n=normalize(input.value); if(n){ set(n); if(api.onChange)api.onChange(n); } else input.value=get(); });
+ const api={el:root,get,set,onChange:null};
+ return api;
+ }
+ // ---- 锚定下拉面板(透明遮罩点外关闭,不冻结窗口) ----
+ let pop=null, mode='', target=null, panes=[], onRange=null;
+ function build(m){
+ if(pop && mode===m) return;
+ if(pop) pop.remove();
+ mode=m; panes=[];
+ pop=document.createElement('div'); pop.className='wheel-pop';
+ if(m==='single'){
+ const p=makePane(); panes=[p]; pop.appendChild(p.el);
+ pop.insertAdjacentHTML('beforeend','');
+ pop.querySelector('.wheel-today').onclick=()=>p.set(today());
+ pop.querySelector('.wheel-ok').onclick=commitSingle;
+ } else {
+ const a=makePane('起始日期'), b=makePane('结束日期'); panes=[a,b];
+ const row=document.createElement('div'); row.className='wheel-range'; row.appendChild(a.el); row.appendChild(b.el); pop.appendChild(row);
+ pop.insertAdjacentHTML('beforeend','');
+ const prev=pop.querySelector('.wheel-preview');
+ a.onChange=b.onChange=()=>{ prev.textContent=a.get()+' ~ '+b.get(); };
+ pop.querySelector('.wheel-ok').onclick=commitRange;
+ }
+ document.body.appendChild(pop);
+ }
+ function position(el){ const r=el.getBoundingClientRect(); const w=pop.offsetWidth;
+ pop.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px'; pop.style.top=(r.bottom+6)+'px';
+ const ph=pop.offsetHeight; if(r.bottom+6+ph+8>window.innerHeight && r.top-ph-6>8) pop.style.top=(r.top-ph-6)+'px'; }
+
+ function open(el){ build('single'); target=el;
+ pop.classList.add('show'); // 先显示获得布局,scrollTop 才能生效
+ panes[0].set(el.dataset.val||''); position(el); }
+ function openRange(el,opt){ opt=opt||{}; build('range'); target=el; onRange=opt.onCommit||null;
+ pop.classList.add('show');
+ panes[0].set(opt.from||''); panes[1].set(opt.to||'');
+ pop.querySelector('.wheel-preview').textContent=panes[0].get()+' ~ '+panes[1].get();
+ position(el); }
+ function close(){ if(pop) pop.classList.remove('show'); target=null; onRange=null; }
+ function commitSingle(){ if(!target) return; const val=panes[0].get(); target.dataset.val=val;
+ const dv=target.querySelector('.dval'); if(dv){ dv.textContent=val; dv.classList.remove('ph'); }
+ target.classList.remove('error');
+ target.dispatchEvent(new CustomEvent('datechange',{bubbles:true,detail:{value:val}})); close(); }
+ function commitRange(){ let f=panes[0].get(), t=panes[1].get(); if(t
');
+ const prev=host.querySelector('.wheel-preview');
+ const upd=()=>{ prev.textContent=a.get()+' ~ '+b.get(); };
+ a.onChange=b.onChange=upd;
+ a.set(opt.from||''); b.set(opt.to||''); upd();
+ host.querySelector('.wheel-ok').onclick=()=>{ let f=a.get(), t=b.get(); if(t