feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
Reference in New Issue
Block a user