Files
Toolbox/public/tools/thought_lab/labs/mall_food_checkin/index.html
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

411 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>宝龙美食打卡 · 思想实验室</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--line:#eceef1;--txt:#1f2329;--sub:#8a9099;
--brand:#ff6b35;--brand2:#ff9a3d;--ok:#22b573;--gray:#d5d9df;--r:14px}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{margin:0;background:var(--bg);color:var(--txt);padding-bottom:96px;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:820px;margin:0 auto;padding:0 12px}
header{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#fff;padding:18px 16px 20px;border-radius:0 0 20px 20px}
header h1{margin:0;font-size:19px}
header p{margin:5px 0 0;font-size:12px;opacity:.92}
.stat{display:flex;gap:9px;margin-top:14px}
.stat div{flex:1;background:rgba(255,255,255,.18);border-radius:12px;padding:8px 4px;text-align:center}
.stat b{display:block;font-size:19px;line-height:1.25}
.stat span{font-size:11px;opacity:.92}
.bar{height:7px;background:rgba(255,255,255,.28);border-radius:6px;margin-top:12px;overflow:hidden}
.bar i{display:block;height:100%;background:#fff;width:0;transition:width .4s}
.tools{background:#fff;border-radius:var(--r);padding:10px;margin:12px 0 8px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.search{width:100%;border:1px solid var(--line);border-radius:10px;padding:9px 12px;font-size:14px;outline:none}
.chips{display:flex;gap:6px;overflow-x:auto;margin-top:9px}
.chips::-webkit-scrollbar{display:none}
.chip{flex:0 0 auto;border:1px solid var(--line);background:#fff;border-radius:20px;padding:6px 13px;font-size:13px;color:var(--sub);cursor:pointer}
.chip.on{background:var(--txt);color:#fff;border-color:var(--txt)}
.chips.f2 .chip.on{background:var(--brand);border-color:var(--brand)}
.floor{color:var(--sub);font-size:12px;margin:15px 2px 7px;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.card{background:var(--card);border-radius:var(--r);overflow:hidden;position:relative;
box-shadow:0 1px 3px rgba(0,0,0,.06);cursor:pointer;transition:transform .12s}
.card:active{transform:scale(.97)}
.card.done{box-shadow:0 1px 4px rgba(34,181,115,.28)}
.pic{position:relative;height:0;padding-bottom:62%;background:#f0f1f3;overflow:hidden}
.pic img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.pic .ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:26px;color:#c8ccd2}
.tick{position:absolute;top:7px;right:7px;width:24px;height:24px;border-radius:50%;
background:rgba(255,255,255,.92);border:2px solid var(--gray);display:flex;align-items:center;justify-content:center;
font-size:13px;color:#fff;z-index:2;box-shadow:0 1px 3px rgba(0,0,0,.15)}
.tick:after{content:"✓";opacity:0;font-weight:700}
.card.done .tick{background:var(--ok);border-color:var(--ok)}
.card.done .tick:after{opacity:1}
.bd{padding:9px 10px 10px}
.nm{font-size:14px;font-weight:600;line-height:1.3}
.cat{font-size:11px;color:var(--sub);margin-top:2px}
.stars{margin-top:6px;font-size:17px;line-height:1;letter-spacing:1px}
.stars span{color:var(--gray)}
.stars span.on{color:#ffb400}
.cmt{margin-top:5px;font-size:11px;color:#6b7280;background:#f7f8fa;border-radius:7px;padding:5px 7px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.cmt.add{color:var(--brand);background:#fff5f0;text-align:center;display:block}
.card.done .cmt{background:#eef8f2}
.del{position:absolute;top:7px;left:7px;z-index:3;background:#ff4d4f;color:#fff;font-size:11px;
border-radius:20px;padding:3px 9px}
.empty{text-align:center;color:var(--sub);font-size:13px;padding:40px 0}
nav{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--line);
display:flex;justify-content:space-around;padding:8px 0 calc(8px + env(safe-area-inset-bottom));z-index:20}
nav button{background:none;border:0;font-size:12px;color:var(--sub);display:flex;flex-direction:column;align-items:center;gap:2px}
nav button i{font-style:normal;font-size:18px}
nav button.on{color:var(--brand)}
.mask{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;z-index:30;align-items:flex-end}
.mask.show{display:flex}
.sheet{background:#fff;width:100%;border-radius:18px 18px 0 0;padding:16px 16px calc(20px + env(safe-area-inset-bottom));animation:up .22s ease}
@keyframes up{from{transform:translateY(100%)}}
.sheet h3{margin:0 0 6px;font-size:16px}
.sheet .sm{font-size:12px;color:var(--sub);margin-bottom:10px}
.sheet label{font-size:12px;color:var(--sub);display:block;margin:10px 0 5px}
.sheet input,.sheet textarea,.sheet select{width:100%;border:1px solid var(--line);border-radius:10px;
padding:10px;font-size:14px;font-family:inherit;outline:none;background:#fff}
.sheet textarea{min-height:72px;resize:none}
.big{font-size:30px;letter-spacing:3px;color:var(--gray);margin:2px 0 6px;line-height:1.2}
.big span.on{color:#ffb400}
.row{display:flex;gap:9px;margin-top:15px}
.btn{flex:1;border:0;border-radius:11px;padding:12px;font-size:15px;font-weight:600;cursor:pointer}
.btn[disabled]{opacity:.55;cursor:not-allowed}
.btn.p{background:var(--brand);color:#fff}
.btn.g{background:#f2f3f5;color:var(--txt)}
.btn.d{background:#fff0ee;color:#e5484d}
.tip{font-size:11px;color:var(--sub);margin-top:10px;line-height:1.5;text-align:center}
.hide{display:none}
.toast{position:fixed;left:50%;bottom:82px;transform:translateX(-50%);background:rgba(0,0,0,.82);
color:#fff;font-size:13px;padding:9px 16px;border-radius:20px;z-index:50;opacity:0;transition:.25s;pointer-events:none}
.toast.show{opacity:1}
.toast.ok{background:rgba(34,181,115,.92)}
.toast.err{background:rgba(229,72,77,.94)}
.toast.info{background:rgba(37,99,235,.92)}
/* 交互详情 / 确认警告 模态窗口(Rule 7 双模式体系) */
.dlgmask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;z-index:40;align-items:center;justify-content:center;padding:18px}
.dlgmask.show{display:flex}
.dlg{background:#fff;border-radius:16px;max-width:560px;width:100%;max-height:78vh;overflow:auto;padding:16px}
.dlg h3{margin:0 0 8px;font-size:16px}
.dlg h3.danger{color:#e5484d}
.dlg .sm{font-size:12px;color:var(--sub);margin-bottom:10px;line-height:1.6}
.dlg pre{background:#f7f8fa;border-radius:10px;padding:10px;font-size:11px;line-height:1.7;
white-space:pre-wrap;word-break:break-all;max-height:46vh;overflow:auto;margin:0 0 12px}
</style>
</head>
<body>
<header><div class="wrap">
<h1 id="hTitle">宝龙美食打卡</h1>
<p id="hSub">加载中…</p>
<div class="stat">
<div><b id="sAll">0</b><span>总店数</span></div>
<div><b id="sDone">0</b><span>已打卡</span></div>
<div><b id="sLeft">0</b><span>还没吃</span></div>
<div><b id="sAvg">-</b><span>平均分</span></div>
</div>
<div class="bar"><i id="bar"></i></div>
</div></header>
<div class="wrap">
<div class="tools">
<input class="search" id="q" placeholder="搜店名 / 品类,比如 火锅">
<div class="chips" id="fStatus">
<div class="chip on" data-s="all">全部</div>
<div class="chip" data-s="done">吃过的</div>
<div class="chip" data-s="todo">没吃的</div>
<div class="chip" data-s="star">我的高分</div>
</div>
<div class="chips f2" id="fFloor"></div>
</div>
<div id="list"><div class="empty">加载中…</div></div>
<div class="tip">数据存在服务端 SQLite · 换设备自动同步 | 导出仅为你主动备份</div>
</div>
<nav>
<button id="navAdd"><i>+</i>加店铺</button>
<button id="navEdit"><i>✎</i>整理</button>
<button id="navOut"><i>⇩</i>导出</button>
<button id="navIn"><i>⇧</i>导入</button>
</nav>
<div class="mask" id="mask"><div class="sheet" id="sheet"></div></div>
<div class="dlgmask" id="dlgmask"><div class="dlg" id="dlg"></div></div>
<div class="toast" id="toast"></div>
<input type="file" id="file" accept=".json,application/json" class="hide">
<script>
const API='/api/thought_lab/mall_food_checkin';
let SHOPS=[], FLOORS=[], MY={}, META={}, UI_MODE='normal';
let fStatus='all', fFloor='all', kw='', editMode=false, pending=false;
const $=id=>document.getElementById(id);
function today(){const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}
function bjNow(){ try{ return new Date().toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}); }catch(e){ return new Date().toLocaleString('zh-CN'); } }
function toast(t,kind){ const e=$('toast'); e.textContent=t; e.className='toast show'+(kind?' '+kind:''); clearTimeout(e._t); e._t=setTimeout(()=>e.classList.remove('show'),2000); }
function esc(s){ return String(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function rec(id){ return MY[id] || (MY[id]={d:0,s:0,n:'',t:''}); }
function setLoading(btn,on,text){ if(!btn) return; if(on){ btn._t=btn.textContent; btn.disabled=true; btn.textContent=text||'处理中…'; } else { btn.disabled=false; if(btn._t) btn.textContent=btn._t; } }
async function api(p,opts){
const r=await fetch(API+p,Object.assign({headers:{'Content-Type':'application/json'}},opts||{}));
if(!r.ok){ let msg='HTTP '+r.status; try{ const j=await r.json(); if(j&&j.error) msg=j.error; }catch(e){} throw new Error(msg); }
return r.json();
}
// ------------------------------------------------------------
// 交互详情跟踪(Rule 7):记录触发动作/时间/接口/方法/参数/结果/错误/耗时/状态
// 双模式(Rule 10):debug=成功也弹详情窗 3s;normal=成功仅 Toast,失败才弹详情窗且停留
// ------------------------------------------------------------
let dlgTimer=null, dlgCdTimer=null;
function closeDlg(){ clearTimeout(dlgTimer); clearInterval(dlgCdTimer); $('dlgmask').classList.remove('show'); }
function openInterDialog(info,autoMs){
clearTimeout(dlgTimer); clearInterval(dlgCdTimer);
const lines=[['触发动作',info.action],['时间',info.time],['接口',info.url],['方法',info.method],
['请求参数',info.params||'-'],['返回结果',info.result||'-'],['错误',info.error||'-'],
['耗时',info.ms+'ms'],['状态',info.status]];
const text=lines.map(l=>l[0]+': '+l[1]).join('\n');
$('dlg').innerHTML='<h3 class="'+(info.status==='fail'?'danger':'')+'">'+(info.status==='fail'?'⚠ 交互失败 · ':'交互详情 · ')+esc(info.action)+'</h3>'+
'<pre id="dlgPre">'+esc(text)+'</pre>'+
'<div class="row"><button class="btn g" id="dCopy">复制</button><button class="btn g" id="dClose">关闭<span id="dCd"></span></button></div>';
$('dlgmask').classList.add('show');
$('dCopy').onclick=()=>{ try{ navigator.clipboard&&navigator.clipboard.writeText(text); }catch(e){} toast('已复制交互日志','info'); };
$('dClose').onclick=closeDlg;
if(autoMs>0){
let left=Math.ceil(autoMs/1000); const cd=$('dCd'); cd.textContent=' '+left+'s';
dlgCdTimer=setInterval(()=>{ left--; if(left<=0){ closeDlg(); return; } cd.textContent=' '+left+'s'; },1000);
dlgTimer=setTimeout(closeDlg,autoMs);
}
}
function showInteraction(info){
if(info.status==='fail'){ openInterDialog(info,0); return; }
if(UI_MODE==='debug') openInterDialog(info,3000);
}
async function track(action,method,url,params,fn){
const t0=Date.now();
const info={action,time:bjNow(),url:API+url,method,params:params===undefined?'':JSON.stringify(params),result:'',error:'',ms:0,status:'pending'};
try{
const res=await fn();
info.ms=Date.now()-t0;
if(res&&res.ok===false){ info.status='fail'; info.error=String(res.error||'business_error'); info.result=JSON.stringify(res); showInteraction(info); throw new Error(info.error); }
info.status='ok'; info.result='ok'; showInteraction(info);
return res;
}catch(e){
if(info.status!=='fail'){ info.ms=Date.now()-t0; info.status='fail'; info.error=String(e&&e.message||e); showInteraction(info); }
throw e;
}
}
// 确认警告窗口(Rule 7/10):删除等高危操作双模式均保留,永不降级
function confirmDanger(title,desc){
return new Promise(res=>{
clearTimeout(dlgTimer); clearInterval(dlgCdTimer);
$('dlg').innerHTML='<h3 class="danger">'+esc(title)+'</h3><div class="sm">'+esc(desc)+'</div>'+
'<div class="row"><button class="btn g" id="cNo">取消</button><button class="btn d" id="cYes">确认删除</button></div>';
$('dlgmask').classList.add('show');
$('cNo').onclick=()=>{ closeDlg(); res(false); };
$('cYes').onclick=()=>{ closeDlg(); res(true); };
});
}
$('dlgmask').addEventListener('click',e=>{ if(e.target===$('dlgmask')) closeDlg(); });
// ------------------------------------------------------------
// 渲染
// ------------------------------------------------------------
function render(){
const q=kw.trim().toLowerCase();
const arr=SHOPS.filter(s=>{
const r=rec(s.id);
if(fStatus==='done'&&!r.d) return false;
if(fStatus==='todo'&&r.d) return false;
if(fStatus==='star'&&r.s<4) return false;
if(fFloor!=='all'&&s.floor!==fFloor) return false;
if(q&&!((s.name+s.cat+(r.n||''))).toLowerCase().includes(q)) return false;
return true;
});
const done=SHOPS.filter(s=>rec(s.id).d).length;
const rated=SHOPS.filter(s=>rec(s.id).d&&rec(s.id).s>0);
const avg=rated.length?(rated.reduce((a,s)=>a+rec(s.id).s,0)/rated.length).toFixed(1):'-';
$('sAll').textContent=SHOPS.length; $('sDone').textContent=done;
$('sLeft').textContent=SHOPS.length-done; $('sAvg').textContent=avg;
$('bar').style.width=(SHOPS.length?done/SHOPS.length*100:0)+'%';
$('fFloor').innerHTML='<div class="chip'+(fFloor==='all'?' on':'')+'" data-f="all">全部楼层</div>'+
FLOORS.map(f=>'<div class="chip'+(fFloor===f?' on':'')+'" data-f="'+f+'">'+f+'</div>').join('');
if(!arr.length){ $('list').innerHTML='<div class="empty">没有匹配的店铺</div>'; return; }
const g={}; arr.forEach(s=>(g[s.floor]=g[s.floor]||[]).push(s));
let h='';
FLOORS.concat(Object.keys(g).filter(f=>!FLOORS.includes(f))).forEach(f=>{
if(!g[f]) return;
const dn=g[f].filter(s=>rec(s.id).d).length;
h+='<div class="floor">'+f+' · '+dn+'/'+g[f].length+'</div><div class="grid">'+g[f].map(cardHTML).join('')+'</div>';
});
$('list').innerHTML=h;
}
function cardHTML(s){
const r=rec(s.id);
let st=''; for(let i=1;i<=5;i++) st+='<span class="'+(i<=r.s?'on':'')+'">★</span>';
const pic = s.img ? '<img src="'+esc(s.img)+'" loading="lazy" alt="">' : '<div class="ph">🍽</div>';
return '<div class="card'+(r.d?' done':'')+'" data-id="'+s.id+'">'+
(editMode?'<div class="del">✕ 删除</div>':'')+
'<div class="pic">'+pic+'<div class="tick"></div></div>'+
'<div class="bd"><div class="nm">'+esc(s.name)+'</div>'+
'<div class="cat">'+esc(s.cat||'')+'</div>'+
(r.d?'<div class="stars">'+st+'</div>':'')+
(r.n?'<div class="cmt">'+esc(r.n)+'</div>':(r.d?'<div class="cmt add">+ 写评价</div>':''))+
'</div></div>';
}
// ------------------------------------------------------------
// 交互:卡片点击(打卡 / 评分 / 删除 / 编辑)
// ------------------------------------------------------------
$('list').addEventListener('click',async e=>{
const c=e.target.closest('.card'); if(!c||pending) return;
const s=SHOPS.find(x=>x.id===c.dataset.id); if(!s) return;
const url='/shops/'+encodeURIComponent(s.id);
if(e.target.classList.contains('del')){
const yes=await confirmDanger('删除店铺','确定把「'+s.name+'」从清单删除?其打卡记录也会一起删除,不可恢复。');
if(!yes) return;
pending=true;
try{
await track('删除店铺','DELETE',url,{id:s.id},()=>api(url,{method:'DELETE'}));
SHOPS=SHOPS.filter(x=>x.id!==s.id); delete MY[s.id]; render(); toast('已删除','ok');
}catch(err){ toast('删除失败:'+err.message,'err'); }
pending=false; return;
}
if(editMode){ openSheet(s); return; }
if(e.target.classList.contains('add')){ openSheet(s); return; }
const r=rec(s.id);
const rurl='/records/'+encodeURIComponent(s.id);
if(r.d && e.target.tagName==='SPAN' && e.target.parentNode.classList.contains('stars')){
const i=[...e.target.parentNode.children].indexOf(e.target)+1;
const nr={d:1,s:(r.s===i?i-1:i),n:r.n,t:r.t};
pending=true;
try{
await track('修改评分','PUT',rurl,nr,()=>api(rurl,{method:'PUT',body:JSON.stringify(nr)}));
MY[s.id]=nr; render();
}catch(err){ toast('评分失败:'+err.message,'err'); }
pending=false; return;
}
const nd=r.d?0:1;
const nr=nd?{d:1,s:r.s,n:r.n,t:r.t||today()}:{d:0,s:0,n:r.n,t:r.t};
pending=true;
try{
await track(nd?'打卡':'取消打卡','PUT',rurl,nr,()=>api(rurl,{method:'PUT',body:JSON.stringify(nr)}));
MY[s.id]=nr; render();
toast(nd?'打卡成功 · 点星星评分':'已取消打卡', nd?'ok':'info');
}catch(err){ toast('操作失败:'+err.message,'err'); }
pending=false;
});
$('q').addEventListener('input',e=>{kw=e.target.value;render();});
$('fStatus').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;
fStatus=c.dataset.s;[...e.currentTarget.children].forEach(x=>x.classList.toggle('on',x===c));render();});
$('fFloor').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;fFloor=c.dataset.f;render();});
// ------------------------------------------------------------
// 表单弹窗:新增 / 编辑 / 评价
// ------------------------------------------------------------
const mask=$('mask'), sheet=$('sheet');
mask.addEventListener('click',e=>{if(e.target===mask)mask.classList.remove('show');});
function openSheet(s){
const isNew=!s;
s=s||{id:'',name:'',floor:(FLOORS[FLOORS.length-1]||'B1'),cat:'',img:''};
const r=isNew?{s:0,n:'',t:today()}:rec(s.id);
sheet.innerHTML='<h3>'+(isNew?'添加店铺':esc(s.name))+'</h3>'+
(isNew?'':'<div class="sm">'+esc(s.floor)+(s.cat?' · '+esc(s.cat):'')+'</div>')+
(isNew||editMode?'<label>店名</label><input id="iName" value="'+esc(s.name)+'">':'')+
(isNew?'<label>楼层</label><select id="iFloor">'+FLOORS.map(f=>'<option'+(f===s.floor?' selected':'')+'>'+f+'</option>').join('')+'</select>':'')+
(isNew||editMode?'<label>品类</label><input id="iCat" value="'+esc(s.cat)+'" placeholder="火锅 / 面馆 / 咖啡">':'')+
'<label>我的评分</label><div class="big" id="iStar"></div>'+
'<label>评价 / 备注</label><textarea id="iNote" placeholder="好吃吗?推荐菜?人均多少?">'+esc(r.n)+'</textarea>'+
'<label>打卡日期</label><input id="iDate" type="date" value="'+(r.t||today())+'">'+
'<div class="row">'+(isNew?'':'<button class="btn d" id="bDel">删除</button>')+
'<button class="btn g" id="bCancel">取消</button><button class="btn p" id="bSave">保存</button></div>'+
'<div class="tip">保存后自动标记为「已吃过」· 数据写入服务端</div>';
mask.classList.add('show');
let star=r.s||0, box=$('iStar');
const draw=()=>{let t='';for(let i=1;i<=5;i++)t+='<span class="'+(i<=star?'on':'')+'">★</span>';box.innerHTML=t;};
draw();
box.onclick=e=>{if(e.target===box)return;const i=[...box.children].indexOf(e.target)+1;star=(star===i?i-1:i);draw();};
$('bCancel').onclick=()=>mask.classList.remove('show');
$('bSave').onclick=async()=>{
const btn=$('bSave'); if(btn.disabled) return;
let name=s.name;
if(isNew||editMode){ name=$('iName').value.trim(); if(!name){toast('店名不能为空','err');return;} }
const cat=(($('iCat')||{}).value||'').trim();
const floor=isNew?($('iFloor')||{}).value:s.floor;
const nr={d:1,s:star,n:$('iNote').value.trim(),t:$('iDate').value};
setLoading(btn,true,'保存中…');
try{
let sid=s.id;
if(isNew){
const ra=await track('新增店铺','POST','/shops',{name,floor,cat},()=>api('/shops',{method:'POST',body:JSON.stringify({name,floor,cat})}));
sid=ra.id; SHOPS.push({id:sid,name,floor,cat,img:''});
}else if(editMode){
await track('修改店铺','PUT','/shops/'+encodeURIComponent(sid),{name,floor,cat},()=>api('/shops/'+encodeURIComponent(sid),{method:'PUT',body:JSON.stringify({name,floor,cat})}));
const sh=SHOPS.find(x=>x.id===sid); if(sh){ sh.name=name; sh.cat=cat; }
}
await track('保存打卡记录','PUT','/records/'+encodeURIComponent(sid),nr,()=>api('/records/'+encodeURIComponent(sid),{method:'PUT',body:JSON.stringify(nr)}));
MY[sid]=nr; render(); mask.classList.remove('show'); toast('已保存','ok');
}catch(err){ toast('保存失败:'+err.message,'err'); }
setLoading(btn,false,'保存');
};
const bd=$('bDel');
if(bd) bd.onclick=async()=>{
if(bd.disabled) return;
const yes=await confirmDanger('删除店铺','确定删除「'+s.name+'」?打卡记录一并删除,不可恢复。');
if(!yes) return;
setLoading(bd,true,'删除中…');
try{
await track('删除店铺','DELETE','/shops/'+encodeURIComponent(s.id),{id:s.id},()=>api('/shops/'+encodeURIComponent(s.id),{method:'DELETE'}));
SHOPS=SHOPS.filter(x=>x.id!==s.id); delete MY[s.id]; render(); mask.classList.remove('show'); toast('已删除','ok');
}catch(err){ toast('删除失败:'+err.message,'err'); setLoading(bd,false,'删除'); }
};
}
$('navAdd').onclick=()=>openSheet(null);
$('navEdit').onclick=()=>{ editMode=!editMode; $('navEdit').classList.toggle('on',editMode);
toast(editMode?'整理模式:点卡片可改名 / 删除':'已退出整理模式','info'); render(); };
$('navOut').onclick=async()=>{
if(pending) return; pending=true;
try{
const d=await track('导出备份','GET','/export',undefined,()=>api('/export'));
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([JSON.stringify(d,null,1)],{type:'application/json'}));
a.download='宝杨宝龙打卡_'+today()+'.json'; a.click(); toast('备份已导出','ok');
}catch(err){ toast('导出失败:'+err.message,'err'); }
pending=false;
};
$('navIn').onclick=()=>$('file').click();
$('file').onchange=e=>{ const f=e.target.files[0]; if(!f) return;
const fr=new FileReader();
fr.onload=async()=>{
let d; try{ d=JSON.parse(fr.result); }catch(err){ toast('文件格式不对','err'); e.target.value=''; return; }
try{
const r=await track('导入备份','POST','/import',{records:Object.keys(d.records||{}).length,extra:(d.extra||[]).length},()=>api('/import',{method:'POST',body:JSON.stringify(d)}));
await loadState(false);
toast('导入成功 · 新增 '+r.added+' 店 / 合并 '+r.merged+' 条','ok');
}catch(err){ toast('导入失败:'+err.message,'err'); }
e.target.value='';
};
fr.readAsText(f); };
// ------------------------------------------------------------
// 启动:数据唯一来源为服务端 API(不再内嵌 SEED / 不再 fetch 静态 json)
// ------------------------------------------------------------
async function loadState(tracked){
const fn=()=>api('/state');
const d=tracked? await track('加载数据','GET','/state',undefined,fn) : await fn();
UI_MODE=d.ui_mode==='debug'?'debug':'normal';
META={mall:d.mall||'',city:d.city||'',address:d.address||'',updated:d.updated||''};
SHOPS=d.shops||[]; MY=d.records||{}; FLOORS=(d.floors&&d.floors.length)?d.floors:['3F','2F','1F','B1'];
$('hTitle').textContent=(META.mall||'宝龙美食')+' · 美食打卡';
$('hSub').textContent=[META.city,META.address].filter(Boolean).join(' · ')+' | 吃过的打个勾,顺手写一笔';
render();
}
loadState(true).catch(err=>{
$('list').innerHTML='<div class="empty">加载失败:'+esc(err.message)+'<br>请从首页「思想实验室」进入本 lab</div>';
});
</script>
</body>
</html>