624 lines
32 KiB
HTML
624 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<title>睡眠打卡</title>
|
||
<link rel="icon" type="image/png" href="icon-180.png">
|
||
<link rel="apple-touch-icon" href="icon-180.png">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box;}
|
||
body{font-family:-apple-system,sans-serif;background:#0f0e2a;color:#e0e0e0;min-height:100vh;}
|
||
|
||
/* 登录 */
|
||
.login{display:flex;position:fixed;inset:0;z-index:999;align-items:center;justify-content:center;flex-direction:column;
|
||
background:radial-gradient(ellipse at 30% 20%,rgba(102,126,234,0.15),transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(168,85,247,0.1),transparent 50%),#0f0e2a;}
|
||
.login.hidden{display:none;}
|
||
.login-logo{font-size:56px;margin-bottom:16px;animation:logoFloat 3s ease-in-out infinite;}
|
||
@keyframes logoFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
|
||
.login h1{font-size:26px;color:white;font-weight:700;}
|
||
.login p{font-size:13px;color:rgba(255,255,255,0.35);margin-top:4px;margin-bottom:24px;}
|
||
.login-card{background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px 24px;width:300px;backdrop-filter:blur(12px);}
|
||
.login-form{display:flex;flex-direction:column;gap:12px;}
|
||
.iw{position:relative;}
|
||
.login-form input{width:100%;padding:14px 42px 14px 16px;border:1.5px solid rgba(255,255,255,0.1);border-radius:12px;background:rgba(255,255,255,0.04);color:white;font-size:15px;outline:none;transition:border-color 0.2s;}
|
||
.login-form input::placeholder{color:rgba(255,255,255,0.25);}
|
||
.login-form input:focus{border-color:rgba(167,139,250,0.5);background:rgba(255,255,255,0.06);}
|
||
.eye{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:16px;color:rgba(255,255,255,0.3);}
|
||
.lmb{width:100%;padding:14px;border:none;border-radius:12px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-size:16px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(102,126,234,0.3);}
|
||
.lmb:active{transform:scale(0.98);}
|
||
.ltg{background:none;border:none;color:rgba(255,255,255,0.35);font-size:13px;cursor:pointer;margin-top:4px;text-align:center;display:block;width:100%;}
|
||
.ltg:hover{color:rgba(255,255,255,0.6);}
|
||
.lerr{color:#fca5a5;font-size:13px;margin-top:8px;min-height:20px;text-align:center;}
|
||
|
||
/* 主体 */
|
||
.container{max-width:480px;margin:0 auto;padding:16px;}
|
||
.header{display:flex;align-items:center;margin-bottom:14px;}
|
||
.header h2{font-size:18px;color:white;flex:1;}
|
||
.user-chip{display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:10px;background:rgba(255,255,255,0.08);cursor:pointer;font-size:13px;color:rgba(255,255,255,0.6);position:relative;}
|
||
.user-chip:hover{background:rgba(255,255,255,0.12);}
|
||
.user-menu{display:none;position:absolute;top:36px;right:0;background:rgba(30,30,60,0.95);border:1px solid rgba(255,255,255,0.1);border-radius:10px;overflow:hidden;z-index:100;min-width:100px;backdrop-filter:blur(12px);}
|
||
.user-menu.open{display:block;}
|
||
.user-menu button{display:block;width:100%;padding:10px 16px;border:none;background:none;color:rgba(255,255,255,0.7);font-size:13px;cursor:pointer;text-align:left;}
|
||
.user-menu button:hover{background:rgba(255,255,255,0.08);}
|
||
|
||
/* 通知 */
|
||
.notif-bar{background:linear-gradient(135deg,#667eea,#764ba2);border-radius:14px;padding:14px;margin-bottom:14px;text-align:center;cursor:pointer;animation:fadeIn 0.4s;}
|
||
.notif-bar.mini{padding:8px 14px;border-radius:10px;font-size:12px;text-align:left;display:flex;align-items:center;gap:8px;}
|
||
@keyframes fadeIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}
|
||
|
||
/* 按钮 */
|
||
.sleep-btn{display:block;width:100%;padding:20px;border:none;border-radius:18px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-size:20px;font-weight:700;cursor:pointer;margin-bottom:14px;box-shadow:0 6px 24px rgba(102,126,234,0.4);}
|
||
.sleep-btn:active{transform:scale(0.97);}
|
||
|
||
.card{background:rgba(255,255,255,0.06);border-radius:14px;padding:16px;margin-bottom:14px;border:1px solid rgba(255,255,255,0.08);}
|
||
.card h3{font-size:14px;color:rgba(255,255,255,0.5);margin-bottom:10px;font-weight:500;}
|
||
|
||
.ir{display:flex;gap:8px;}
|
||
.ir input{flex:1;padding:10px 14px;border:1.5px solid rgba(255,255,255,0.12);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:15px;outline:none;}
|
||
.ir input:focus{border-color:#667eea;}
|
||
.ir input::placeholder{color:rgba(255,255,255,0.25);}
|
||
.sb{padding:10px 16px;border:none;border-radius:10px;font-size:14px;cursor:pointer;background:#667eea;color:white;}
|
||
.hint{font-size:12px;min-height:18px;margin-top:6px;color:rgba(255,255,255,0.3);}
|
||
|
||
/* 用户选择器 */
|
||
.user-toggles{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
|
||
.ut{padding:4px 10px;border-radius:8px;border:1.5px solid rgba(255,255,255,0.15);background:none;color:rgba(255,255,255,0.4);font-size:12px;cursor:pointer;}
|
||
.ut.on{border-color:var(--c);color:var(--c);background:rgba(255,255,255,0.06);}
|
||
|
||
/* 统计 */
|
||
.cmp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px;}
|
||
.cmp-box{background:rgba(255,255,255,0.04);border-radius:10px;padding:10px;text-align:center;border:1px solid rgba(255,255,255,0.06);}
|
||
.cmp-name{font-size:10px;color:rgba(255,255,255,0.3);margin-bottom:4px;}
|
||
.cmp-val{font-size:16px;font-weight:700;}
|
||
.cmp-label{font-size:10px;color:rgba(255,255,255,0.25);margin-top:2px;}
|
||
|
||
.chart-canvas{width:100%;height:220px;display:block;}
|
||
|
||
.record{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);font-size:13px;}
|
||
.record:last-child{border-bottom:none;}
|
||
.utag{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;}
|
||
.ebtn{background:none;border:none;color:rgba(255,255,255,0.2);cursor:pointer;font-size:11px;}
|
||
.ebtn:hover{color:#667eea;}
|
||
|
||
.celebrate{text-align:center;padding:10px;background:rgba(34,197,94,0.1);border-radius:10px;margin-bottom:14px;border:1px solid rgba(34,197,94,0.15);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="login" id="loginOverlay">
|
||
<div class="login-logo">🌙</div>
|
||
<h1>睡眠打卡</h1>
|
||
<p id="loginSubtitle">和好友一起早睡</p>
|
||
<div class="login-card">
|
||
<div class="login-form">
|
||
<div class="iw"><input type="text" id="loginUser" placeholder="用户名" onkeydown="if(event.key==='Enter'&&!event.isComposing)document.getElementById('loginPwd').focus()"></div>
|
||
<div class="iw">
|
||
<input type="password" id="loginPwd" placeholder="密码" onkeydown="if(event.key==='Enter'&&!event.isComposing){loginMode==='login'?doLogin():document.getElementById('loginPwd2').focus()}">
|
||
<button class="eye" onclick="const p=document.getElementById('loginPwd');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'👁🗨'">👁</button>
|
||
</div>
|
||
<div class="iw" id="pwd2Wrap" style="display:none;">
|
||
<input type="password" id="loginPwd2" placeholder="确认密码" onkeydown="if(event.key==='Enter'&&!event.isComposing)doRegister()">
|
||
<button class="eye" onclick="const p=document.getElementById('loginPwd2');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'👁🗨'">👁</button>
|
||
</div>
|
||
<button class="lmb" id="loginMainBtn" onclick="doLogin()">登录</button>
|
||
<button class="ltg" id="loginToggleBtn" onclick="toggleLoginMode()">没有账号?注册</button>
|
||
<div class="lerr" id="loginErr"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div class="header">
|
||
<h2>🌙 睡眠打卡</h2>
|
||
<div class="user-chip" onclick="document.getElementById('userMenu').classList.toggle('open')">
|
||
<span id="headerUser"></span> ▾
|
||
<div class="user-menu" id="userMenu">
|
||
<button onclick="doLogout()">退出登录</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="notifArea"></div>
|
||
<div id="celebrateArea"></div>
|
||
|
||
<button class="sleep-btn" id="sleepBtn" onclick="goSleep()">🌙 我去睡觉啦</button>
|
||
<div id="notifMini" style="display:none;"></div>
|
||
|
||
<div class="card" style="padding:12px 16px;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||
<span style="font-size:13px;color:rgba(255,255,255,0.4);">我的目标入睡时间</span>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span id="myTargetDisplay" style="font-size:15px;color:#86efac;font-weight:600;">22:00</span>
|
||
<button onclick="setMyTarget()" style="padding:4px 10px;border:none;border-radius:8px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.4);font-size:12px;cursor:pointer;">修改</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>记录 / 修改入睡时间</h3>
|
||
<div class="ir">
|
||
<input id="sleepInput" placeholder="昨晚11点半 / 10:30" onkeydown="if(event.key==='Enter'&&!event.isComposing)addRecord()">
|
||
<button class="sb" onclick="addRecord()">记录</button>
|
||
</div>
|
||
<div class="hint" id="recordHint"></div>
|
||
</div>
|
||
|
||
<!-- 统计对比 -->
|
||
<div class="card">
|
||
<h3>数据对比</h3>
|
||
<div class="cmp-grid" id="cmpGrid"></div>
|
||
</div>
|
||
|
||
<!-- 睡眠曲线 -->
|
||
<div class="card">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
||
<h3 style="margin:0;">睡眠趋势</h3>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<button onclick="changeChartMonth(-1)" style="width:26px;height:26px;border-radius:50%;border:none;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);cursor:pointer;">‹</button>
|
||
<span id="chartMonthLabel" style="font-size:12px;color:rgba(255,255,255,0.4);min-width:60px;text-align:center;"></span>
|
||
<button onclick="changeChartMonth(1)" style="width:26px;height:26px;border-radius:50%;border:none;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);cursor:pointer;">›</button>
|
||
</div>
|
||
</div>
|
||
<div class="user-toggles" id="userToggles"></div>
|
||
<canvas class="chart-canvas" id="mainChart"></canvas>
|
||
</div>
|
||
|
||
<!-- 最近记录 -->
|
||
<div class="card">
|
||
<h3>最近记录</h3>
|
||
<div id="recordList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API='/api/sleep-buddy';
|
||
let myName='', loginMode='login';
|
||
let selectedUsers=new Set(); // 图表上选中的用户
|
||
|
||
async function hashStr(s){const buf=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s));return Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');}
|
||
|
||
const saved=JSON.parse(localStorage.getItem('buddy_session')||'null');
|
||
if(saved&&Date.now()<saved.exp){myName=saved.username;document.getElementById('loginOverlay').classList.add('hidden');startApp();}
|
||
|
||
function toggleLoginMode(){
|
||
loginMode=loginMode==='login'?'register':'login';
|
||
const isReg=loginMode==='register';
|
||
document.getElementById('pwd2Wrap').style.display=isReg?'block':'none';
|
||
document.getElementById('loginMainBtn').textContent=isReg?'注册':'登录';
|
||
document.getElementById('loginMainBtn').onclick=isReg?doRegister:doLogin;
|
||
document.getElementById('loginToggleBtn').textContent=isReg?'已有账号?登录':'没有账号?注册';
|
||
document.getElementById('loginSubtitle').textContent=isReg?'创建你的账号':'和好友一起早睡';
|
||
document.getElementById('loginUser').placeholder=isReg?'起一个昵称':'用户名';
|
||
document.getElementById('loginErr').textContent='';
|
||
}
|
||
|
||
async function doLogin(){
|
||
const user=document.getElementById('loginUser').value.trim(),pwd=document.getElementById('loginPwd').value,err=document.getElementById('loginErr');
|
||
if(!user||!pwd){err.textContent='请输入用户名和密码';return;}
|
||
const r=await fetch('/api/buddy-login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,hash:await hashStr(pwd)})}).then(r=>r.json());
|
||
if(r.ok){myName=user;localStorage.setItem('buddy_session',JSON.stringify({username:user,exp:Date.now()+30*86400000}));document.getElementById('loginOverlay').classList.add('hidden');startApp();}
|
||
else{err.textContent=r.error||'登录失败';}
|
||
}
|
||
|
||
async function doRegister(){
|
||
const user=document.getElementById('loginUser').value.trim(),pwd=document.getElementById('loginPwd').value,pwd2=document.getElementById('loginPwd2').value,err=document.getElementById('loginErr');
|
||
err.style.color='#fca5a5';
|
||
if(!user){err.textContent='请输入用户名';return;} if(!pwd){err.textContent='请输入密码';return;}
|
||
if(pwd.length<4){err.textContent='密码至少4位';return;} if(pwd!==pwd2){err.textContent='两次密码不一致';return;}
|
||
const r=await fetch('/api/buddy-register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,hash:await hashStr(pwd)})}).then(r=>r.json());
|
||
if(r.ok){err.style.color='#86efac';err.textContent='注册成功!';loginMode='login';setTimeout(()=>doLogin(),500);}
|
||
else{err.textContent=r.error||'注册失败';}
|
||
}
|
||
|
||
function doLogout(){localStorage.removeItem('buddy_session');location.reload();}
|
||
|
||
function startApp(){
|
||
document.getElementById('headerUser').textContent=myName;
|
||
selectedUsers.add(myName);
|
||
loadData();setInterval(loadData,30000);
|
||
checkNotifs();setInterval(checkNotifs,10000);
|
||
// 切回页面时立即检查通知
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden){checkNotifs();loadData();}});
|
||
}
|
||
|
||
// 关闭菜单
|
||
document.addEventListener('click',e=>{if(!e.target.closest('.user-chip'))document.getElementById('userMenu')?.classList.remove('open');});
|
||
|
||
function parseSleepTime(text){
|
||
const m1=text.match(/(\d{1,2})[点::](\d{1,2})?/);if(!m1)return null;
|
||
let h=parseInt(m1[1]),m=m1[2]?parseInt(m1[2]):0;
|
||
if(text.includes('半')&&m===0)m=30;
|
||
if(h>=9&&h<=11)h+=12;else if(h===12)h=0;
|
||
m=Math.round(m/10)*10;if(m===60){m=0;h++;}if(h>=24)h-=24;
|
||
const d=new Date();if(/昨/.test(text))d.setDate(d.getDate()-1);
|
||
return{date:d.toISOString().slice(0,10),start:`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`};
|
||
}
|
||
|
||
async function addRecord(){
|
||
const input=document.getElementById('sleepInput'),hint=document.getElementById('recordHint');
|
||
const r=parseSleepTime(input.value);
|
||
if(!r){hint.style.color='#ef4444';hint.textContent='无法识别,试试:昨晚11点半';return;}
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'record',record:r})});
|
||
hint.style.color='#86efac';hint.textContent=`✓ ${r.date} ${fmtTime(r.start)}`;
|
||
input.value='';loadData();
|
||
}
|
||
|
||
async function goSleep(){
|
||
const now=new Date();let h=now.getHours(),m=Math.round(now.getMinutes()/10)*10;
|
||
if(m===60){m=0;h++;}if(h>=24)h-=24;
|
||
const record={date:now.toISOString().slice(0,10),start:`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`};
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'record',record})});
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'sleep-now'})});
|
||
const btn=document.getElementById('sleepBtn');
|
||
btn.textContent='🌙 晚安!已通知好友';btn.style.opacity='0.6';
|
||
setTimeout(()=>{btn.textContent='🌙 我去睡觉啦';btn.style.opacity='1';},3000);
|
||
loadData();
|
||
}
|
||
|
||
function showModal(html) {
|
||
let m=document.getElementById('sleepModal');
|
||
if(!m){m=document.createElement('div');m.id='sleepModal';
|
||
m.style.cssText='display:flex;position:fixed;inset:0;z-index:999;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
|
||
document.body.appendChild(m);}
|
||
m.innerHTML=`<div style="background:#1a1940;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:24px;width:280px;animation:fadeIn 0.2s;">${html}</div>`;
|
||
m.style.display='flex';
|
||
}
|
||
function hideModal(){const m=document.getElementById('sleepModal');if(m)m.style.display='none';}
|
||
|
||
function editRecord(user,date){
|
||
if(user!==myName)return;
|
||
showModal(`
|
||
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:12px;">修改 ${date.slice(5)} 入睡时间</div>
|
||
<input type="time" id="modalTimeInput" style="width:100%;padding:12px;border:1.5px solid rgba(255,255,255,0.15);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:16px;outline:none;margin-bottom:14px;">
|
||
<div style="display:flex;gap:8px;">
|
||
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
|
||
<button onclick="doEditRecord('${user}','${date}')" style="flex:1;padding:10px;border:none;border-radius:10px;background:#667eea;color:white;cursor:pointer;">保存</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async function doEditRecord(user,date){
|
||
const time=document.getElementById('modalTimeInput')?.value;
|
||
if(!time){return;}
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user,action:'record',record:{date,start:time}})});
|
||
hideModal();loadData();
|
||
}
|
||
|
||
function deleteRecord(user,date){
|
||
if(user!==myName)return;
|
||
showModal(`
|
||
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:14px;">确定删除 ${date.slice(5)} 的记录?</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
|
||
<button onclick="doDeleteRecord('${user}','${date}')" style="flex:1;padding:10px;border:none;border-radius:10px;background:#ef4444;color:white;cursor:pointer;">删除</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async function doDeleteRecord(user,date){
|
||
// 用空记录覆盖来"删除"(服务器端只接受record操作)
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user,action:'delete-record',date})});
|
||
hideModal();loadData();
|
||
}
|
||
|
||
async function checkNotifs(){
|
||
try{
|
||
const data=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'get-notifications'})}).then(r=>r.json());
|
||
const lastSeen=parseFloat(localStorage.getItem('buddy_notif_seen')||'0');
|
||
const news=(data.notifications||[]).filter(n=>n.ts>lastSeen);
|
||
if(news.length>0){
|
||
localStorage.setItem('buddy_notif_seen',String(Math.max(...news.map(n=>n.ts))));
|
||
const area=document.getElementById('notifArea');
|
||
area.innerHTML=`<div class="notif-bar" onclick="this.remove();document.getElementById('notifMini').style.display='block';document.getElementById('notifMini').innerHTML='<div class=\\'notif-bar mini\\'><span>🌙</span> ${news[0].from} 去睡了</div>'">
|
||
<div style="font-size:28px;">🌙</div>
|
||
<div style="font-size:14px;color:rgba(255,255,255,0.9);margin-top:6px;">${news[0].msg}</div>
|
||
<div style="font-size:11px;color:rgba(255,255,255,0.4);margin-top:4px;">点击收起</div>
|
||
</div>`;
|
||
if(navigator.vibrate)navigator.vibrate([200,100,200]);
|
||
if(Notification.permission==='granted')new Notification('睡眠打卡',{body:news[0].msg});
|
||
}
|
||
}catch(e){}
|
||
}
|
||
|
||
// 目标时间设置
|
||
let userTargets={}; // {username: "22:00"}
|
||
|
||
function setMyTarget(){
|
||
document.getElementById('userMenu')?.classList.remove('open');
|
||
const cur=userTargets[myName]||'22:00';
|
||
showModal(`
|
||
<div style="font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:12px;">设置你的目标入睡时间</div>
|
||
<input type="time" id="modalTargetInput" value="${cur}" style="width:100%;padding:12px;border:1.5px solid rgba(255,255,255,0.15);border-radius:10px;background:rgba(255,255,255,0.06);color:white;font-size:16px;outline:none;margin-bottom:14px;">
|
||
<div style="display:flex;gap:8px;">
|
||
<button onclick="hideModal()" style="flex:1;padding:10px;border:none;border-radius:10px;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.6);cursor:pointer;">取消</button>
|
||
<button onclick="doSetTarget()" style="flex:1;padding:10px;border:none;border-radius:10px;background:#667eea;color:white;cursor:pointer;">保存</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async function doSetTarget(){
|
||
const t=document.getElementById('modalTargetInput')?.value;
|
||
if(!t)return;
|
||
await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:myName,action:'set-target',target:t})});
|
||
userTargets[myName]=t;
|
||
const td=document.getElementById('myTargetDisplay');
|
||
if(td)td.textContent=t;
|
||
hideModal();loadData();
|
||
}
|
||
|
||
function getTargetMins(name){
|
||
const t=userTargets[name]||'22:00';
|
||
const[h,m]=t.split(':').map(Number);
|
||
return(h<12?h+24:h)*60+m-20*60;
|
||
}
|
||
|
||
// 数据
|
||
const COLORS=['#a78bfa','#f472b6','#34d399','#fbbf24','#60a5fa'];
|
||
let chartViewMonth=new Date(); // 当前查看的月份
|
||
|
||
function changeChartMonth(delta){
|
||
chartViewMonth=new Date(chartViewMonth.getFullYear(),chartViewMonth.getMonth()+delta,1);
|
||
const now=new Date();
|
||
if(chartViewMonth>now)chartViewMonth=new Date(now.getFullYear(),now.getMonth(),1);
|
||
loadData();
|
||
}
|
||
|
||
async function loadData(){
|
||
try{
|
||
const data=await fetch(API).then(r=>r.json());
|
||
const users=data.users||{};
|
||
userTargets=data.targets||{};
|
||
const td=document.getElementById('myTargetDisplay');
|
||
if(td)td.textContent=userTargets[myName]||'22:00';
|
||
const names=Object.keys(users);
|
||
const sorted=[myName,...names.filter(n=>n!==myName)].filter(n=>users[n]);
|
||
|
||
// 用户选择器
|
||
const tgl=document.getElementById('userToggles');
|
||
tgl.innerHTML=sorted.map((n,i)=>{
|
||
const c=COLORS[i%COLORS.length];
|
||
const on=selectedUsers.has(n);
|
||
return`<button class="ut ${on?'on':''}" style="--c:${c}" onclick="toggleUser('${n}')">${n}${n===myName?' (我)':''}</button>`;
|
||
}).join('');
|
||
|
||
// 统计对比
|
||
renderCompare(sorted,users);
|
||
// 图表
|
||
renderChart(sorted,users);
|
||
// 早睡庆祝
|
||
renderCelebrate(users);
|
||
// 记录
|
||
renderRecords(users);
|
||
}catch(e){}
|
||
}
|
||
|
||
function toggleUser(name){
|
||
if(name===myName)return; // 自己始终选中
|
||
if(selectedUsers.has(name))selectedUsers.delete(name);else selectedUsers.add(name);
|
||
loadData();
|
||
}
|
||
|
||
function sleepMins(s){const[h,m]=s.split(':').map(Number);return(h<12?h+24:h)*60+m-20*60;}
|
||
function fmtTime(s){const[h,m]=s.split(':').map(Number);const p=h<6?'凌晨':h<12?'上午':h<18?'下午':'晚上';const dh=h>12?h-12:h===0?12:h;return`${p}${dh}:${m.toString().padStart(2,'0')}`;}
|
||
|
||
function renderCompare(names,users){
|
||
const grid=document.getElementById('cmpGrid');
|
||
const active=names.filter(n=>selectedUsers.has(n));
|
||
let html='';
|
||
active.forEach((name,i)=>{
|
||
const recs=users[name]||[];
|
||
const c=COLORS[names.indexOf(name)%COLORS.length];
|
||
const last7=recs.slice(0,7);
|
||
const last30=recs.slice(0,30);
|
||
if(!last7.length){html+=`<div class="cmp-box"><div class="cmp-name" style="color:${c}">${name}</div><div style="color:rgba(255,255,255,0.2);font-size:11px;">暂无数据</div></div>`;return;}
|
||
|
||
const tgt=getTargetMins(name);
|
||
const tgtTime=userTargets[name]||'22:00';
|
||
const avg7=Math.round(last7.map(r=>sleepMins(r.start)).reduce((a,b)=>a+b,0)/last7.length);
|
||
const early7=last7.filter(r=>sleepMins(r.start)<=tgt).length;
|
||
const early30=last30.filter(r=>sleepMins(r.start)<=tgt).length;
|
||
const toStr=off=>{let t=off+20*60;if(t>=1440)t-=1440;return`${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`;};
|
||
|
||
html+=`<div class="cmp-box">
|
||
<div class="cmp-name" style="color:${c}">${name}${name===myName?' (我)':''}</div>
|
||
<div class="cmp-val" style="color:${c}">${fmtTime(toStr(avg7))}</div>
|
||
<div class="cmp-label">近7天平均</div>
|
||
<div style="margin-top:6px;font-size:12px;color:rgba(255,255,255,0.4);">
|
||
${tgtTime}前:${early7}/7天
|
||
${last30.length>=14?`<br>月达标:${early30}/${last30.length}天`:''}
|
||
</div>
|
||
</div>`;
|
||
});
|
||
grid.innerHTML=html;
|
||
}
|
||
|
||
function renderChart(names,users){
|
||
const canvas=document.getElementById('mainChart');if(!canvas)return;
|
||
const dpr=window.devicePixelRatio||1,rect=canvas.getBoundingClientRect();
|
||
canvas.width=rect.width*dpr;canvas.height=rect.height*dpr;
|
||
const ctx=canvas.getContext('2d');ctx.scale(dpr,dpr);
|
||
const W=rect.width,H=rect.height;ctx.clearRect(0,0,W,H);
|
||
|
||
const year=chartViewMonth.getFullYear(),month=chartViewMonth.getMonth();
|
||
const daysInMonth=new Date(year,month+1,0).getDate();
|
||
document.getElementById('chartMonthLabel').textContent=`${year}年${month+1}月`;
|
||
|
||
const pL=44,pR=12,pT=22,pB=30,cW=W-pL-pR,cH=H-pT-pB;
|
||
const yMax=300; // 21:00=0 → 02:00=300
|
||
const yScale=cH/yMax;
|
||
|
||
// Y轴网格 + 标签
|
||
const timeLabels=[{off:0,l:'21:00'},{off:60,l:'22:00'},{off:120,l:'23:00'},{off:180,l:'0:00'},{off:240,l:'1:00'},{off:300,l:'2:00'}];
|
||
timeLabels.forEach(tl=>{
|
||
const y=pT+tl.off*yScale;
|
||
ctx.strokeStyle='rgba(255,255,255,0.06)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(pL,y);ctx.lineTo(W-pR,y);ctx.stroke();
|
||
ctx.fillStyle='rgba(255,255,255,0.25)';ctx.font='10px -apple-system,sans-serif';ctx.textAlign='right';
|
||
ctx.fillText(tl.l,pL-6,y+4);
|
||
});
|
||
|
||
// 目标参考线(用当前用户的目标时间)
|
||
const myTgt=getTargetMins(myName);
|
||
const myTgtTime=userTargets[myName]||'22:00';
|
||
const refOff=myTgt-60; // sleepMins offset from 21:00
|
||
const refY=pT+Math.max(0,Math.min(refOff,300))*yScale;
|
||
ctx.strokeStyle='rgba(34,197,94,0.35)';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);
|
||
ctx.beginPath();ctx.moveTo(pL,refY);ctx.lineTo(W-pR,refY);ctx.stroke();ctx.setLineDash([]);
|
||
ctx.fillStyle='rgba(34,197,94,0.5)';ctx.font='10px -apple-system,sans-serif';ctx.textAlign='left';
|
||
ctx.fillText(`${myTgtTime} 目标`,W-pR-58,refY-5);
|
||
|
||
// X轴日期标签
|
||
ctx.textAlign='center';
|
||
for(let d=1;d<=daysInMonth;d++){
|
||
if(d%5===1||d===daysInMonth){
|
||
const x=pL+(d-0.5)/daysInMonth*cW;
|
||
ctx.fillStyle='rgba(255,255,255,0.25)';ctx.font='10px -apple-system,sans-serif';
|
||
ctx.fillText(`${d}`,x,H-pB+16);
|
||
}
|
||
}
|
||
|
||
const today=new Date();
|
||
const todayDay=(today.getFullYear()===year&&today.getMonth()===month)?today.getDate():daysInMonth;
|
||
const active=names.filter(n=>selectedUsers.has(n));
|
||
const allChartPoints=[]; // for tooltip
|
||
|
||
// 画每个选中用户
|
||
active.forEach((name,ni)=>{
|
||
const recs=users[name]||[];
|
||
const c=COLORS[names.indexOf(name)%COLORS.length];
|
||
const isPrimary=ni===0;
|
||
|
||
// 建立日期→分钟映射
|
||
const dataMap={};
|
||
recs.forEach(r=>{
|
||
const[ry,rm,rd]=r.date.split('-').map(Number);
|
||
if(ry===year&&rm===month+1)dataMap[rd]=sleepMins(r.start);
|
||
});
|
||
|
||
// 预估缺失天(仅主用户)
|
||
function estimateForDay(d){
|
||
const vals=[];
|
||
for(let i=1;i<=5&&vals.length<3;i++){if(dataMap[d-i]!==undefined)vals.push(dataMap[d-i]);}
|
||
return vals.length>0?Math.round(vals.reduce((a,b)=>a+b,0)/vals.length):null;
|
||
}
|
||
|
||
const pts=[];
|
||
for(let d=1;d<=daysInMonth;d++){
|
||
if(d>todayDay)continue;
|
||
const x=pL+(d-0.5)/daysInMonth*cW;
|
||
let offsetVal,isReal=true;
|
||
if(dataMap[d]!==undefined){offsetVal=dataMap[d];}
|
||
else if(isPrimary){offsetVal=estimateForDay(d);isReal=false;}
|
||
else continue;
|
||
if(offsetVal===null)continue;
|
||
const off=offsetVal-60;
|
||
const clamped=Math.max(0,Math.min(off,yMax));
|
||
const y=pT+clamped*yScale;
|
||
const dotColor=off<=60?'#22c55e':off<=120?'#f59e0b':'#ef4444';
|
||
pts.push({x,y,day:d,offset:offsetVal,real:isReal,color:isPrimary?dotColor:c,userColor:c,name});
|
||
}
|
||
|
||
// 折线
|
||
const realPts=pts.filter(p=>p.real);
|
||
if(realPts.length>1){
|
||
ctx.strokeStyle=c;ctx.lineWidth=isPrimary?2.5:2;ctx.lineJoin='round';
|
||
ctx.setLineDash(isPrimary?[]:[4,4]);
|
||
ctx.beginPath();realPts.forEach((p,i)=>{if(!i)ctx.moveTo(p.x,p.y);else ctx.lineTo(p.x,p.y);});ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// 圆点
|
||
pts.forEach(p=>{
|
||
if(p.real){
|
||
ctx.fillStyle='#0f0e2a';ctx.strokeStyle=p.color;ctx.lineWidth=isPrimary?2.5:2;
|
||
ctx.beginPath();ctx.arc(p.x,p.y,isPrimary?4.5:3,0,Math.PI*2);ctx.fill();ctx.stroke();
|
||
}else{
|
||
ctx.strokeStyle='rgba(255,255,255,0.15)';ctx.lineWidth=1.5;ctx.setLineDash([3,3]);
|
||
ctx.beginPath();ctx.arc(p.x,p.y,4,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
|
||
}
|
||
});
|
||
|
||
allChartPoints.push(...pts);
|
||
});
|
||
|
||
// 图例
|
||
ctx.textAlign='left';
|
||
active.forEach((name,ni)=>{
|
||
const c=COLORS[names.indexOf(name)%COLORS.length];
|
||
const x=pL+ni*80;
|
||
ctx.fillStyle=c;ctx.fillRect(x,8,12,3);
|
||
ctx.fillStyle='rgba(255,255,255,0.4)';ctx.font='10px -apple-system,sans-serif';ctx.fillText(name,x+16,11);
|
||
});
|
||
|
||
// Tooltip
|
||
const minsToStr=off=>{let t=off+20*60;if(t>=1440)t-=1440;return`${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`;};
|
||
canvas._chartPoints=allChartPoints;
|
||
canvas._chartMeta={month,minsToStr};
|
||
if(!canvas._tooltipBound){
|
||
canvas._tooltipBound=true;
|
||
const showTip=(cx,cy)=>{
|
||
const r=canvas.getBoundingClientRect(),mx=cx-r.left;
|
||
const pts=canvas._chartPoints||[];
|
||
let closest=null,minD=30;
|
||
pts.forEach(p=>{const d=Math.abs(p.x-mx);if(d<minD){minD=d;closest=p;}});
|
||
let tip=document.getElementById('chartTip');
|
||
if(!tip){tip=document.createElement('div');tip.id='chartTip';
|
||
tip.style.cssText='position:absolute;background:rgba(0,0,0,0.85);color:white;padding:6px 10px;border-radius:8px;font-size:12px;pointer-events:none;z-index:10;white-space:nowrap;transition:opacity 0.15s;';
|
||
canvas.parentElement.style.position='relative';canvas.parentElement.appendChild(tip);}
|
||
if(closest){
|
||
const m=canvas._chartMeta;
|
||
const ts=m.minsToStr(closest.offset);
|
||
const label=closest.real?ts:`~${ts}(预估)`;
|
||
tip.textContent=`${m.month+1}/${closest.day} ${closest.name}: ${label}`;
|
||
tip.style.left=Math.min(closest.x,canvas.getBoundingClientRect().width-100)+'px';
|
||
tip.style.top=(closest.y-32)+'px';tip.style.opacity='1';
|
||
}else{tip.style.opacity='0';}
|
||
};
|
||
canvas.addEventListener('mousemove',e=>showTip(e.clientX,e.clientY));
|
||
canvas.addEventListener('touchstart',e=>showTip(e.touches[0].clientX,e.touches[0].clientY),{passive:true});
|
||
canvas.addEventListener('mouseleave',()=>{const t=document.getElementById('chartTip');if(t)t.style.opacity='0';});
|
||
}
|
||
}
|
||
|
||
function renderCelebrate(users){
|
||
const area=document.getElementById('celebrateArea');
|
||
const today=new Date().toISOString().slice(0,10);
|
||
const earlyUsers=[];
|
||
for(const[name,recs] of Object.entries(users)){
|
||
const r=recs.find(r=>r.date===today);
|
||
if(r&&sleepMins(r.start)<=getTargetMins(name))earlyUsers.push({name,target:userTargets[name]||'22:00'});
|
||
}
|
||
area.innerHTML=earlyUsers.length?`<div class="celebrate"><span style="font-size:24px;">🎉</span> <span style="font-size:13px;color:#86efac;">${earlyUsers.map(u=>u.name).join('、')} 今天达标了!</span></div>`:'';
|
||
}
|
||
|
||
function renderRecords(users){
|
||
const el=document.getElementById('recordList');
|
||
const all=[];
|
||
for(const[name,recs] of Object.entries(users))recs.slice(0,14).forEach(r=>all.push({...r,user:name}));
|
||
all.sort((a,b)=>b.date.localeCompare(a.date));
|
||
if(!all.length){el.innerHTML='<div style="color:rgba(255,255,255,0.2);font-size:13px;">暂无记录</div>';return;}
|
||
const names=Object.keys(users);
|
||
el.innerHTML=all.slice(0,20).map(r=>{
|
||
const isMe=r.user===myName;
|
||
const ci=names.indexOf(r.user);
|
||
const c=COLORS[ci%COLORS.length];
|
||
const myBtns=isMe?`<button class="ebtn" onclick="editRecord('${r.user}','${r.date}')" title="修改">✎</button><button class="ebtn" onclick="deleteRecord('${r.user}','${r.date}')" title="删除" style="color:rgba(255,100,100,0.3);">×</button>`:'';
|
||
return`<div class="record">
|
||
<span style="color:rgba(255,255,255,0.3);min-width:46px;">${r.date.slice(5)}</span>
|
||
<span style="font-weight:500;color:${c};">${fmtTime(r.start)}</span>
|
||
${myBtns}
|
||
<span class="utag" style="background:${c}20;color:${c};margin-left:auto;">${r.user}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
if('Notification'in window&&Notification.permission==='default')Notification.requestPermission();
|
||
|
||
// 自动检测代码更新
|
||
(function(){let kv=null;async function ck(){try{const r=await fetch('/api/version');const d=await r.json();if(kv===null){kv=d.v;return;}if(d.v!==kv)location.reload();}catch(e){}}ck();setInterval(ck,60000);})();
|
||
</script>
|
||
</body>
|
||
</html>
|