feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
@@ -16,9 +16,10 @@
|
||||
// ===== 工具函数 =====
|
||||
const escHtml = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
// 短链接前缀统一为 /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) => {
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
|
||||