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
+2 -1
View File
@@ -16,9 +16,10 @@
// ===== 工具函数 =====
const escHtml = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
// 短链接前缀统一为 /to/:旧前缀 /l/ 在手机小屏字体下与 I、i、1 字形难分,已弃用
const fullShortUrl = code => {
const base = DOMAIN || (location.origin)
return `${base}/l/${code}`
return `${base}/to/${code}`
}
const copyText = async (text) => {
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>
+55
View File
@@ -31,11 +31,66 @@
</header>
<nav id="toolTabs" class="tab-nav" hidden>
<button class="tab-btn" data-tab="emergency">首页导航防爆紧急替换</button>
<button class="tab-btn active" data-tab="nav">首页导航生成器</button>
<button class="tab-btn" data-tab="tool">Tool 令牌生成器</button>
</nav>
<main id="appMain" hidden>
<section class="tab-panel" data-panel="emergency">
<div class="layout-grid">
<section class="card">
<div class="card-head">
<h2>首页导航防爆紧急替换</h2>
</div>
<p class="card-desc">
怀疑首页 token 或短链接被窥屏、被泄露,或有危险预感时,按顺序点下面两个按钮即可完成更换。
旧 token 与旧短链接都会<strong>立即失效</strong>,无需手工改文件、改数据库。
</p>
<div class="actions wrap">
<button id="btnEmergencyRotate" class="btn btn-primary">自动生成向后 X 个月的鉴权,且当前鉴权立即失效</button>
<select id="emergencyMonths" class="input emergency-months" title="有效期(月)"></select>
<button id="btnEmergencyRelink" class="btn btn-primary">将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效</button>
</div>
<label class="field">
<span>执行过程</span>
<textarea id="emergencyLog" class="textarea mono emergency-log" rows="14" readonly wrap="off" placeholder="点击上方按钮后,这里会滚动显示每一步的执行过程…"></textarea>
</label>
<label class="field">
<span>最终 token(第 1 步产出)</span>
<textarea id="emergencyTokenOut" class="textarea mono" rows="4" readonly wrap="off" placeholder="第 1 步完成后,新的首页鉴权 token 会显示在这里"></textarea>
</label>
<div class="actions">
<button id="btnEmergencyCopyToken" class="btn">复制 token</button>
</div>
<label class="field">
<span>最终短链接(第 2 步产出)</span>
<textarea id="emergencyShortUrlOut" class="textarea mono" rows="2" readonly wrap="off" placeholder="第 2 步完成后,新的短链接会显示在这里"></textarea>
</label>
<div class="actions">
<button id="btnEmergencyCopyShortUrl" class="btn">复制短链接</button>
</div>
<div id="emergencyMeta" class="result-grid"></div>
<details class="raw-details">
<summary>说明与回滚</summary>
<div class="card-desc">
第 1 步:轮换导航密钥对(`config/nav-private.jwk.json` 与 `config/nav.jwk.json`),
并用新私钥签发一枚有效期 30 天的首页 token。旧 token 会因旧公钥离开信任池而立即失效。<br>
第 2 步:把新 token 绑到一条全新的短链接上,同时把旧的首页短链置为停用,旧短链立即失效。<br>
两步执行前都会自动备份(密钥文件 + 短链库)到 `config/_nav-key-backup-时间戳/`,需要回滚时把备份覆盖回原位即可。<br>
原有的手工做法(首页导航生成器 / Tool 令牌生成器 / 物理替换文件)全部保留有效,本页签只是快捷通道。
</div>
</details>
</section>
</div>
</section>
<section class="tab-panel active" data-panel="nav">
<div class="layout-grid">
<section class="card">
+28
View File
@@ -269,6 +269,7 @@ body {
.actions.wrap {
flex-wrap: wrap;
align-items: center;
}
.btn {
@@ -289,6 +290,33 @@ body {
background: linear-gradient(135deg, #6f7bf7 0%, #7c55f2 100%);
}
/* 异步按钮执行中:禁用态视觉(配合 JS 的 disabled + 文案切换) */
.btn-busy,
.btn:disabled {
opacity: 0.62;
cursor: not-allowed;
}
/* 防爆紧急替换:有效期月份下拉,紧跟在按钮后面,宽度收窄不抢按钮视线 */
.input.emergency-months {
width: auto;
min-width: 118px;
padding: 11px 14px;
align-self: center;
}
/* 防爆紧急替换的执行日志:长行自动换行 + 纵向滚动,避免横向撑开 */
.textarea.mono.emergency-log {
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: anywhere;
overflow-x: hidden;
overflow-y: auto;
min-height: 240px;
max-height: 380px;
line-height: 1.75;
}
.auth-submit {
width: 100%;
}
+173 -1
View File
@@ -9,7 +9,9 @@
toolPresets: [],
currentPresetId: '',
toolKeys: null,
authenticated: false
authenticated: false,
// 防爆紧急替换:第 1 步产出的新 token,供第 2 步绑定短链接使用
emergencyToken: ''
}
const el = {
@@ -33,6 +35,12 @@
navVerifyMeta: $('#navVerifyMeta'),
navVerifyRaw: $('#navVerifyRaw'),
emergencyLog: $('#emergencyLog'),
emergencyMonths: $('#emergencyMonths'),
emergencyTokenOut: $('#emergencyTokenOut'),
emergencyShortUrlOut: $('#emergencyShortUrlOut'),
emergencyMeta: $('#emergencyMeta'),
toolPresetSelect: $('#toolPresetSelect'),
toolPresetName: $('#toolPresetName'),
toolBaseUrl: $('#toolBaseUrl'),
@@ -744,6 +752,163 @@
URL.revokeObjectURL(url)
}
// ===== 首页导航防爆紧急替换 =====
// 两个按钮串行:先轮换密钥+签发新 token,再把新 token 绑到新短链并让旧短链失效。
// 执行过程按后端回传的步骤逐行滚动展示,最后把 token / 短链接单独显示,方便直接复制。
const EMERGENCY_STEP_DELAY = 110
const EMERGENCY_MONTH_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const EMERGENCY_DEFAULT_MONTHS = 1
// 每月按 30 天计算:与后端 days 参数对接,避免月初月末的天数歧义
const EMERGENCY_DAYS_PER_MONTH = 30
/** 初始化月份下拉:1~12 个月,默认 1 个月,并让按钮文字跟着选中值走 */
function initEmergencyMonths() {
el.emergencyMonths.innerHTML = EMERGENCY_MONTH_OPTIONS
.map(m => `<option value="${m}">${m} 个月</option>`)
.join('')
el.emergencyMonths.value = String(EMERGENCY_DEFAULT_MONTHS)
el.emergencyMonths.addEventListener('change', renderEmergencyRotateButtonText)
renderEmergencyRotateButtonText()
}
function currentEmergencyMonths() {
const m = Number(el.emergencyMonths.value)
return EMERGENCY_MONTH_OPTIONS.includes(m) ? m : EMERGENCY_DEFAULT_MONTHS
}
/** 按钮文案随下拉实时变化,避免"固定写一个月、实际却按别的月份执行"的歧义 */
function renderEmergencyRotateButtonText() {
const btn = $('#btnEmergencyRotate')
if (!btn || btn.disabled) return // 执行中不要覆盖"执行中…"的文案
btn.textContent = `自动生成向后 ${currentEmergencyMonths()} 个月的鉴权,且当前鉴权立即失效`
}
/** 按钮异步态:禁用 + 文字切换(项目规则:所有异步按钮都要有 loading) */
function setButtonBusy(btn, busy, busyText) {
if (!btn) return
if (busy) {
if (btn.dataset.originalText === undefined) btn.dataset.originalText = btn.textContent
btn.disabled = true
btn.classList.add('btn-busy')
if (busyText) btn.textContent = busyText
} else {
btn.disabled = false
btn.classList.remove('btn-busy')
if (btn.dataset.originalText !== undefined) {
btn.textContent = btn.dataset.originalText
delete btn.dataset.originalText
}
}
}
function emergencyLogClear() {
el.emergencyLog.value = ''
el.emergencyMeta.innerHTML = ''
}
function emergencyLogAppend(line) {
const stamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
el.emergencyLog.value += `[${stamp}] ${line}\n`
el.emergencyLog.scrollTop = el.emergencyLog.scrollHeight
}
/** 逐行播放后端回传的步骤,营造滚动输出的效果 */
async function emergencyLogPlay(lines) {
for (const line of lines || []) {
emergencyLogAppend(line)
await new Promise(resolve => setTimeout(resolve, EMERGENCY_STEP_DELAY))
}
}
/** 与 fetchJson 的区别:失败时也把后端返回的 logs 取回来展示,便于排障 */
async function emergencyPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
})
let data = null
try { data = await resp.json() } catch { data = null }
if (resp.status === 401) {
state.authenticated = false
applyAuthUi()
}
return { ok: !!(resp.ok && data && data.ok !== false), data }
}
async function handleEmergencyRotate() {
const btn = $('#btnEmergencyRotate')
const months = currentEmergencyMonths()
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogClear()
state.emergencyToken = ''
el.emergencyTokenOut.value = ''
el.emergencyShortUrlOut.value = ''
emergencyLogAppend(`开始执行:自动生成向后 ${months} 个月的鉴权,且当前鉴权立即失效`)
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/rotate', { months })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 1 步未成功,请查看上面的执行过程')
setNotice('第 1 步失败,请查看执行过程。', 'error')
return
}
state.emergencyToken = String(data.token || '')
el.emergencyTokenOut.value = state.emergencyToken
const effectiveDays = Number(data.days) || months * EMERGENCY_DAYS_PER_MONTH
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '有效期', value: `${months} 个月(${effectiveDays} 天)` },
{ label: '公钥指纹', value: data.fingerprint || '-' },
{ label: '过期(北京时间)', value: formatEpoch(data.exp) },
{ label: '剩余时长', value: formatRelative(data.exp), status: 'good' },
{ label: '备份目录', value: data.backupDir || '-' }
])
setNotice(`第 1 步完成:旧鉴权已失效,新的 ${months} 个月 token 已生成。`, 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 1 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
// 按钮文案依赖下拉选中值,恢复时按当前选择重算,而不是回退到旧文案
renderEmergencyRotateButtonText()
}
}
async function handleEmergencyRelink() {
const btn = $('#btnEmergencyRelink')
if (!state.emergencyToken) {
emergencyLogAppend('✘ 还没有可用的新 token,请先点第 1 个按钮生成。')
setNotice('请先执行第 1 步生成新 token。', 'error')
return
}
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogAppend('开始执行:将新 token 绑定到新的短链接,且旧短链接立即失效')
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/relink', { token: state.emergencyToken })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 2 步未成功,请查看上面的执行过程')
setNotice('第 2 步失败,请查看执行过程。', 'error')
return
}
el.emergencyShortUrlOut.value = String(data.shortUrl || '')
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '新短码', value: data.code || '-' },
{ label: '已停用的旧短码', value: (data.disabledCodes || []).join('、') || '(无)' },
{ label: '跳转自校验', value: data.verified ? '通过' : '未通过(请手工确认)', status: data.verified ? 'good' : 'bad' }
])
setNotice('第 2 步完成:新短链接已生效,旧短链接已失效。', 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 2 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
}
}
function bindEvents() {
el.tabButtons.forEach((btn) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
@@ -752,6 +917,12 @@
$('#btnNavGenerate').addEventListener('click', handleNavGenerate)
$('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。'))
$('#btnNavVerify').addEventListener('click', handleNavVerify)
// 首页导航防爆紧急替换
$('#btnEmergencyRotate').addEventListener('click', handleEmergencyRotate)
$('#btnEmergencyRelink').addEventListener('click', handleEmergencyRelink)
$('#btnEmergencyCopyToken').addEventListener('click', () => copyText(el.emergencyTokenOut.value, 'token 已复制。'))
$('#btnEmergencyCopyShortUrl').addEventListener('click', () => copyText(el.emergencyShortUrlOut.value, '短链接已复制。'))
$('#btnAuthLogin').addEventListener('click', handleAuthLogin)
el.authUsername.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleAuthLogin()
@@ -801,6 +972,7 @@
loadPresets()
renderPresetSelect()
bindEvents()
initEmergencyMonths()
el.navBaseUrl.value = `${window.location.origin}/`
el.navExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 30)
el.toolExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 7)
+112 -2
View File
@@ -6,7 +6,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#080b12">
<title>框子 - 运动提醒宝</title>
<title>框子</title>
<link rel="icon" href="/tools/web_order_box/favicon.ico">
<link rel="stylesheet" href="/tools/web_order_box/order_box.css">
</head>
@@ -28,6 +28,16 @@
</div>
<svg class="kit-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<button class="kit-card" id="btnEnterSchedule" disabled>
<div class="kit-icon-wrap">
<svg class="kit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>
</div>
<div class="kit-info">
<span class="kit-name">作息报时器</span>
<span class="kit-desc">按「时:分」到点播报、重复提醒、大字时钟常驻待机。</span>
</div>
<svg class="kit-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
@@ -87,7 +97,7 @@
</div>
<!-- 底部 Tab -->
<div class="tab-bar">
<div class="tab-bar" id="motionTabBar">
<button class="tab-item active" data-tab="cards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>卡片</span>
@@ -99,6 +109,80 @@
</div>
</div>
<!-- ===== 作息报时器 主容器 ===== -->
<div id="viewSchedule" class="view" style="display:none">
<div class="motion-header">
<button class="btn-back" id="btnBackHubFromSchedule">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span>返回框子</span>
</button>
</div>
<div class="motion-body">
<!-- 语音不可用预警(作息报时器独立一份) -->
<div id="schedVoiceWarning" class="stale-warning voice-warning" style="display:none">
<div class="stale-title">语音播报可能不可用</div>
<div class="stale-desc" id="schedVoiceWarningDesc">本机系统语音未启动,已尝试云端语音兜底。</div>
<button class="btn-stale-clean" id="btnSchedRetryVoice">重新检测</button>
</div>
<!-- 待机大屏 -->
<div class="standby-panel" id="schedStandbyPanel">
<div class="standby-clock" id="schedClock">--:--:--</div>
<div class="standby-date" id="schedDate">--</div>
<div class="standby-next" id="schedNext">请先选择一套日程</div>
<div class="standby-status" id="schedStatus">未待机</div>
<div class="standby-actions">
<button class="btn-primary standby-btn" id="btnSchedStandby"><span>开始待机</span></button>
</div>
</div>
<!-- 错过显示条 -->
<div id="schedMissed" class="stale-warning missed-warning" style="display:none">
<div class="stale-title">已错过以下时刻</div>
<div class="stale-desc" id="schedMissedDesc"></div>
<button class="btn-stale-clean" id="btnSchedMissedClear">知道了</button>
</div>
<!-- 日程页 -->
<div id="pageSchedules" class="page">
<div class="cards-toolbar">
<button class="btn-primary" id="btnNewSchedule">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span>新建日程</span>
</button>
</div>
<div id="schedulesList" class="cards-list"></div>
<div id="schedulesEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有作息日程</div>
<div class="empty-desc">建一套日程,把起床、做饭、喂鱼这些时刻按「时:分」排进去。</div>
</div>
<div id="schedulesLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
<!-- 播报记录页 -->
<div id="pageSchedLogs" class="page" style="display:none">
<div id="schedLogsList" class="history-list"></div>
<div id="schedLogsEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有播报记录</div>
<div class="empty-desc">开始待机并到点播报后,这里会出现记录。</div>
</div>
<div id="schedLogsLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
</div>
<div class="tab-bar" id="schedTabBar">
<button class="tab-item active" data-sched-tab="schedules">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>
<span>日程</span>
</button>
<button class="tab-item" data-sched-tab="logs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span>记录</span>
</button>
</div>
</div>
<!-- ===== 卡片编辑器 Modal ===== -->
<div id="editorModal" class="modal-overlay" style="display:none">
<div class="modal-sheet">
@@ -121,6 +205,32 @@
</div>
</div>
<!-- ===== 作息日程编辑器 Modal ===== -->
<div id="schedEditorModal" class="modal-overlay" style="display:none">
<div class="modal-sheet">
<div class="sheet-header">
<span class="sheet-title" id="schedEditorTitle">新建作息日程</span>
<button class="btn-icon" id="btnSchedEditorClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="sheet-body" id="schedEditorBody">
<!-- 动态渲染 -->
</div>
</div>
</div>
<!-- ===== 作息播报全屏 Modal ===== -->
<div id="schedAlertModal" class="modal-overlay modal-fullscreen" style="display:none">
<div class="sched-alert-container">
<div class="sched-alert-badge">作息提醒</div>
<div class="sched-alert-time" id="schedAlertTime">--:--</div>
<div class="sched-alert-name" id="schedAlertName"></div>
<div class="sched-alert-repeat" id="schedAlertRepeat"></div>
<button class="btn-primary sched-alert-ack" id="btnSchedAck"><span>我知道了</span></button>
</div>
</div>
<!-- ===== 通用弹窗 ===== -->
<div id="dialogOverlay" class="dialog-overlay" style="display:none">
<div class="dialog-box" id="dialogBox">
+118 -8
View File
@@ -65,7 +65,9 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* ============================================================
视图骨架
============================================================ */
.view { min-height: 100dvh; display: flex; flex-direction: column; }
/* 老浏览器(如老版 iOS Safari)不支持 dvh / inset 简写,会整条丢弃该声明。
凡是用到这些新特性处,先写一份等效的传统写法兜底,再写新写法做渐进增强。 */
.view { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
.page { animation: fadeUp 0.25s ease both; }
/* ============================================================
@@ -218,9 +220,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* ============================================================
Modal(底部抽屉 / 全屏)
============================================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.58); z-index: 100; display: flex; flex-direction: column; justify-content: flex-end; animation: fadeIn 0.2s ease both; }
/* 关键:inset: 0 在老 iOS(<14.1)不被支持,会被整条丢弃,
遮罩将失去定位而跑到文档静态位置(视口外)→ 全屏弹窗和按钮都看不见。
因此这里改写成四条独立定位,任何浏览器都认。 */
.modal-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0,0,0,0.58); z-index: 100; display: flex; flex-direction: column; justify-content: flex-end; animation: fadeIn 0.2s ease both; }
.modal-fullscreen { justify-content: stretch; align-items: stretch; }
.modal-sheet { width: 100%; background: var(--bg); border-radius: var(--radius-xl) var(--radius-xl) 0 0; max-height: 92dvh; display: flex; flex-direction: column; animation: slideUp 0.3s ease-out both; border-top: 1px solid var(--border); }
.modal-sheet { width: 100%; background: var(--bg); border-radius: var(--radius-xl) var(--radius-xl) 0 0; max-height: 92vh; max-height: 92dvh; display: flex; flex-direction: column; animation: slideUp 0.3s ease-out both; border-top: 1px solid var(--border); }
.sheet-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.sheet-title { font-size: 18px; font-weight: 600; }
.sheet-body { flex: 1; overflow-y: auto; padding: 20px; }
@@ -232,14 +237,14 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* ============================================================
通用弹窗 / 交互详情弹窗
============================================================ */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.15s ease both; }
.dialog-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.15s ease both; }
.dialog-box { background: var(--surface); border-radius: var(--radius-lg); padding: 24px; max-width: 340px; width: calc(100% - 48px); animation: scaleIn 0.2s ease both; border: 1px solid var(--border); box-shadow: 0 16px 48px rgba(0,0,0,0.5); }
.dialog-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.dialog-body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 20px; white-space: pre-wrap; word-break: break-word; }
.dialog-actions { display: flex; flex-direction: row; gap: 8px; justify-content: flex-end; }
.dialog-header-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.dialog-header-row .dialog-title { margin-bottom: 0; }
.dialog-interaction { max-width: 420px; max-height: 80dvh; display: flex; flex-direction: column; }
.dialog-interaction { max-width: 420px; max-height: 80vh; max-height: 80dvh; display: flex; flex-direction: column; }
.interaction-content { flex: 1; overflow-y: auto; background: var(--bg); border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 12px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; margin-bottom: 16px; font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace; }
.btn-copy { color: var(--text-secondary); }
.btn-copy:hover { color: var(--primary); }
@@ -361,7 +366,7 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
执行器(全屏)— 匹配 JS 实际 class 名
============================================================ */
.runner-container {
width: 100%; height: 100dvh; background: var(--bg);
width: 100%; height: 100vh; height: 100dvh; background: var(--bg);
display: flex; flex-direction: column; padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
overflow-y: auto; animation: fadeIn 0.25s ease both;
@@ -438,11 +443,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
}
.runner-col-left .runner-next-card { margin-bottom: 0; }
.runner-timer {
font-size: 90px; /* 老浏览器兜底:不认识 clamp 时用这个 */
font-size: clamp(72px, 14vw, 128px);
margin: 8px 0 16px;
}
.runner-current { font-size: clamp(28px, 4.2vw, 46px); }
.runner-next { font-size: clamp(22px, 3vw, 34px); }
.runner-current { font-size: 34px; font-size: clamp(28px, 4.2vw, 46px); }
.runner-next { font-size: 26px; font-size: clamp(22px, 3vw, 34px); }
}
/* 当前动作名 — JS 用 .runner-current */
@@ -497,3 +503,107 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.runner-global-stats { font-size: 13px; color: var(--text-secondary); text-align: center; font-variant-numeric: tabular-nums; display: flex; flex-direction: row; justify-content: center; gap: 4px; flex-wrap: wrap; }
.runner-global-divider { color: var(--text-disabled); }
.runner-global-remaining { color: var(--text-secondary); }
/* ============================================================
作息报时器 — 待机大屏
============================================================ */
.standby-panel {
margin-top: 8px; margin-bottom: 16px; padding: 24px 16px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-xl); text-align: center;
animation: fadeUp 0.3s ease both;
}
.standby-panel.is-live { border-color: rgba(74,222,128,0.35); }
.standby-clock {
/* 先给 vw 兜底(老浏览器不认识 clamp,会丢下一条,但认得 vw,取值一致);
再用 clamp 做上限控制,避免大屏上无限变大 */
font-size: 17vw; font-size: clamp(56px, 17vw, 104px);
font-weight: 700; color: var(--primary);
line-height: 1; letter-spacing: 2px; font-variant-numeric: tabular-nums;
text-shadow: 0 0 36px rgba(240,179,95,0.28);
}
.standby-date { font-size: 15px; color: var(--text-secondary); margin-top: 10px; }
.standby-next { font-size: 18px; font-weight: 600; margin-top: 16px; line-height: 1.4; word-break: break-word; }
.standby-status { font-size: 13px; color: var(--text-disabled); margin-top: 6px; }
.standby-panel.is-live .standby-status { color: var(--success); }
.standby-actions { margin-top: 18px; display: flex; justify-content: center; }
.standby-btn { padding: 12px 32px; font-size: 16px; }
/* 错过显示条:复用 stale-warning 结构,橙色警示 */
.missed-warning { background: rgba(251,191,36,0.05); }
.missed-line { font-size: 13px; color: var(--text-secondary); line-height: 1.7; }
/* ============================================================
作息报时器 — 日程卡片与时刻预览
============================================================ */
.sched-item-preview { margin-top: 8px; }
.sched-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; font-variant-numeric: tabular-nums; }
.sched-preview-line .sched-preview-time { color: var(--primary); font-weight: 600; margin-right: 6px; }
.sched-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
.sched-active-tag {
display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600;
background: rgba(74,222,128,0.15); color: var(--success); margin-left: 8px;
}
/* ============================================================
作息报时器 — 编辑器
============================================================ */
.weekday-row { display: flex; flex-direction: row; gap: 6px; flex-wrap: wrap; }
.weekday-chip {
flex: 1; min-width: 0; padding: 10px 2px; text-align: center;
background: var(--surface-strong); border-radius: var(--radius-sm);
color: var(--text-secondary); font-size: 13px; white-space: nowrap;
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.weekday-chip:hover { color: var(--text); }
.weekday-chip:active { transform: scale(0.94); }
.weekday-chip.active { background: var(--primary); color: #081018; font-weight: 600; }
.sched-item-editor {
background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--primary);
border-radius: var(--radius-md); padding: 14px; margin-bottom: 10px;
animation: fadeUp 0.25s ease both;
}
.sched-time-input {
width: 130px; font-size: 16px; padding: 10px 12px; background: var(--bg);
font-variant-numeric: tabular-nums; color-scheme: dark; margin-bottom: 8px;
}
.sched-item-editor .step-badge { background: var(--primary); color: #081018; }
.sched-item-editor .step-index { font-variant-numeric: tabular-nums; }
.sched-item-row-2 { display: flex; flex-direction: row; gap: 10px; margin-top: 8px; }
.sched-item-row-2 .sched-field { flex: 1; min-width: 0; }
.sched-field-label { display: block; font-size: 12px; color: var(--text-disabled); margin-bottom: 4px; }
.sched-number-input { width: 100%; font-size: 14px; padding: 10px 12px; background: var(--bg); }
.sched-item-hint { font-size: 12px; color: var(--text-disabled); margin-top: 8px; line-height: 1.5; }
/* ============================================================
作息报时器 — 播报全屏
============================================================ */
.sched-alert-container {
width: 100%; height: 100vh; height: 100dvh; background: var(--bg);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 24px; padding-top: calc(24px + env(safe-area-inset-top));
text-align: center; animation: fadeIn 0.25s ease both;
}
.sched-alert-badge {
padding: 4px 16px; border-radius: 999px; font-size: 13px; font-weight: 600;
border: 1px solid var(--primary); color: var(--primary); background: var(--primary-soft);
}
.sched-alert-time {
font-size: 20vw; font-size: clamp(64px, 20vw, 128px);
font-weight: 700; color: var(--primary);
line-height: 1; letter-spacing: 2px; font-variant-numeric: tabular-nums;
text-shadow: 0 0 48px rgba(240,179,95,0.4);
}
.sched-alert-name { font-size: 34px; font-weight: 700; line-height: 1.25; word-break: break-word; }
.sched-alert-repeat { font-size: 15px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.sched-alert-ack {
margin-top: 24px; width: 100%; max-width: 360px; justify-content: center;
padding: 18px; font-size: 20px;
}
/* 记录页条目 */
.sched-log-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.sched-log-badge.badge-fired { background: rgba(240,179,95,0.15); color: var(--primary); }
.sched-log-badge.badge-acked { background: rgba(74,222,128,0.15); color: var(--success); }
.sched-log-badge.badge-missed { background: rgba(239,68,68,0.15); color: var(--error); }
File diff suppressed because it is too large Load Diff