feat: 新增作息报时器功能并替换短链接前缀为/to/

- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
yangxiangyuan
2026-10-09 14:04:37 +08:00
parent ac475b6659
commit a3e8911deb
131 changed files with 7695 additions and 60 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,410 @@
<!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>