Initial commit: Schedule Planner
This commit is contained in:
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-alpine
|
||||
WORKDIR /app
|
||||
COPY server.py .
|
||||
COPY index.html sleep-buddy.html favicon.svg icon-180.png notebook.jpg manifest.json sw.js /app/static/
|
||||
ENV DATA_DIR=/data STATIC_DIR=/app/static PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["python3", "server.py"]
|
||||
316
capture.html
Normal file
316
capture.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="随手记">
|
||||
<meta name="theme-color" content="#667eea">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="apple-touch-icon" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>随手记</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #667eea;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.top {
|
||||
padding: 20px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 16px;
|
||||
padding-right: 16px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: white;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
textarea::placeholder { color: #bbb; }
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid rgba(255,255,255,0.3);
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tag-btn:active { transform: scale(0.95); }
|
||||
.tag-btn.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.save-btn:active { transform: scale(0.97); opacity: 0.9; }
|
||||
.save-btn:disabled { opacity: 0.4; }
|
||||
|
||||
/* 成功反馈 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 20px 32px;
|
||||
border-radius: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.toast .toast-icon { font-size: 32px; display: block; margin-bottom: 6px; }
|
||||
|
||||
/* 最近记录 */
|
||||
.recent {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.recent-title {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.recent-item .ri-tag {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.recent-item .ri-text {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.recent-item .ri-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 13px;
|
||||
padding: 30px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="top">
|
||||
<div class="greeting" id="greeting"></div>
|
||||
<div class="title">随手记</div>
|
||||
<div class="input-area">
|
||||
<textarea id="noteInput" placeholder="想到什么,写下来…" autofocus></textarea>
|
||||
</div>
|
||||
<div class="tags" id="tagBtns">
|
||||
<button class="tag-btn active" data-tag="灵感" onclick="pickTag(this)">💡 灵感</button>
|
||||
<button class="tag-btn" data-tag="备忘" onclick="pickTag(this)">📌 备忘</button>
|
||||
<button class="tag-btn" data-tag="读书" onclick="pickTag(this)">📖 读书</button>
|
||||
<button class="tag-btn" data-tag="待办" onclick="pickTag(this)">✅ 待办</button>
|
||||
</div>
|
||||
<button class="save-btn" id="saveBtn" onclick="saveNote()">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="recent">
|
||||
<div class="recent-title">最近记录</div>
|
||||
<div id="recentList"></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-icon">✨</span>
|
||||
记下了!
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedTag = '灵感';
|
||||
let notes = JSON.parse(localStorage.getItem('sp_notes')) || [];
|
||||
|
||||
// 问候语
|
||||
function updateGreeting() {
|
||||
const h = new Date().getHours();
|
||||
let g = '晚上好';
|
||||
if (h < 6) g = '夜深了,早点休息';
|
||||
else if (h < 12) g = '早上好';
|
||||
else if (h < 14) g = '中午好';
|
||||
else if (h < 18) g = '下午好';
|
||||
document.getElementById('greeting').textContent = g + ',Hera';
|
||||
}
|
||||
updateGreeting();
|
||||
|
||||
function pickTag(btn) {
|
||||
document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
selectedTag = btn.dataset.tag;
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
const input = document.getElementById('noteInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const now = new Date();
|
||||
notes.unshift({
|
||||
text,
|
||||
tag: selectedTag,
|
||||
id: Date.now(),
|
||||
time: now.toLocaleString('zh-CN', {
|
||||
month:'numeric', day:'numeric',
|
||||
hour:'2-digit', minute:'2-digit'
|
||||
}),
|
||||
date: now.toISOString().slice(0,10),
|
||||
});
|
||||
|
||||
localStorage.setItem('sp_notes', JSON.stringify(notes));
|
||||
|
||||
// 如果是"待办"标签,同时丢进收集箱
|
||||
if (selectedTag === '待办') {
|
||||
const inbox = JSON.parse(localStorage.getItem('sp_inbox')) || [];
|
||||
inbox.push({
|
||||
text,
|
||||
id: Date.now(),
|
||||
time: now.toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }),
|
||||
});
|
||||
localStorage.setItem('sp_inbox', JSON.stringify(inbox));
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
showToast();
|
||||
renderRecent();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function showToast() {
|
||||
const t = document.getElementById('toast');
|
||||
const msgs = ['记下了!','保存成功!','已记录 ✓','收到!'];
|
||||
t.innerHTML = `<span class="toast-icon">✨</span>${msgs[Math.floor(Math.random()*msgs.length)]}`;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 1200);
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
const list = document.getElementById('recentList');
|
||||
const recent = notes.slice(0, 10);
|
||||
if (recent.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">还没有记录<br>想到什么就写下来吧</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const TAG_ICONS = { '灵感':'💡', '备忘':'📌', '读书':'📖', '待办':'✅' };
|
||||
list.innerHTML = recent.map(n => `
|
||||
<div class="recent-item">
|
||||
<div class="ri-tag">${TAG_ICONS[n.tag]||'📝'} ${n.tag}</div>
|
||||
<div class="ri-text">${escHtml(n.text)}</div>
|
||||
<div class="ri-time">${n.time}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
document.getElementById('noteInput').addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
});
|
||||
|
||||
renderRecent();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
favicon.svg
Normal file
13
favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="4" y="10" width="56" height="48" rx="8" fill="#667eea"/>
|
||||
<rect x="4" y="10" width="56" height="16" rx="8" fill="#764ba2"/>
|
||||
<rect x="4" y="20" width="56" height="6" fill="#764ba2"/>
|
||||
<circle cx="18" cy="10" r="3" fill="#fff"/>
|
||||
<circle cx="46" cy="10" r="3" fill="#fff"/>
|
||||
<rect x="14" y="34" width="10" height="8" rx="2" fill="#e8f5e9"/>
|
||||
<rect x="27" y="34" width="10" height="8" rx="2" fill="#e3f2fd"/>
|
||||
<rect x="40" y="34" width="10" height="8" rx="2" fill="#fff3e0"/>
|
||||
<rect x="14" y="46" width="10" height="8" rx="2" fill="#fce4ec"/>
|
||||
<rect x="27" y="46" width="10" height="8" rx="2" fill="#f3e5f5"/>
|
||||
<rect x="40" y="46" width="10" height="8" rx="2" fill="#e0f7fa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
BIN
icon-180.png
Normal file
BIN
icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
26
icon-180.svg
Normal file
26
icon-180.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea"/>
|
||||
<stop offset="100%" style="stop-color:#a855f7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 圆角背景 -->
|
||||
<rect width="180" height="180" rx="40" fill="url(#bg)"/>
|
||||
<!-- 装饰圆 -->
|
||||
<circle cx="140" cy="40" r="30" fill="rgba(255,255,255,0.08)"/>
|
||||
<circle cx="30" cy="150" r="20" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- 笔记本图标 -->
|
||||
<rect x="50" y="35" width="80" height="100" rx="8" fill="white" opacity="0.95"/>
|
||||
<rect x="50" y="35" width="80" height="24" rx="8" fill="rgba(102,126,234,0.3)"/>
|
||||
<rect x="50" y="51" width="80" height="8" fill="rgba(102,126,234,0.3)"/>
|
||||
<!-- 横线 -->
|
||||
<line x1="62" y1="75" x2="118" y2="75" stroke="#667eea" stroke-width="2" opacity="0.4"/>
|
||||
<line x1="62" y1="87" x2="108" y2="87" stroke="#667eea" stroke-width="2" opacity="0.3"/>
|
||||
<line x1="62" y1="99" x2="100" y2="99" stroke="#667eea" stroke-width="2" opacity="0.2"/>
|
||||
<!-- 打勾 -->
|
||||
<circle cx="120" cy="110" r="18" fill="#22c55e"/>
|
||||
<polyline points="110,110 117,117 131,103" stroke="white" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- H 字母 -->
|
||||
<text x="90" y="158" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="18" font-weight="700" fill="rgba(255,255,255,0.9)">Hera</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
icon.svg
Normal file
4
icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="108" fill="#667eea"/>
|
||||
<text x="256" y="300" text-anchor="middle" font-size="240" font-family="Arial">✏️</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
7608
index.html
Normal file
7608
index.html
Normal file
File diff suppressed because it is too large
Load Diff
51
k8s-backup-cronjob.yaml
Normal file
51
k8s-backup-cronjob.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: planner-backup-minio
|
||||
namespace: planner
|
||||
spec:
|
||||
schedule: "0 */6 * * *" # every 6 hours
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backup
|
||||
image: python:3.12-alpine
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
apk add --no-cache curl >/dev/null 2>&1
|
||||
curl -sL https://dl.min.io/client/mc/release/linux-arm64/mc -o /usr/local/bin/mc
|
||||
chmod +x /usr/local/bin/mc
|
||||
mc alias set s3 http://minio.minio.svc:9000 admin HpYMIVH0WN79VkzF4L4z8Zx1
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
# Backup main data
|
||||
mc cp /data/planner_data.json "s3/planner-backups/planner_${TS}.json"
|
||||
# Backup buddy data
|
||||
mc cp /data/sleep_buddy.json "s3/planner-backups/buddy_${TS}.json" 2>/dev/null || true
|
||||
# Keep only last 60 backups (10 days worth at 6h interval)
|
||||
mc ls s3/planner-backups/ --json | python3 -c "
|
||||
import sys, json
|
||||
files = []
|
||||
for line in sys.stdin:
|
||||
d = json.loads(line)
|
||||
if d.get('key','').startswith('planner_'):
|
||||
files.append(d['key'])
|
||||
files.sort()
|
||||
for f in files[:-60]:
|
||||
print(f)
|
||||
" | while read f; do mc rm "s3/planner-backups/$f"; done
|
||||
echo "Backup done: ${TS}"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: planner-data
|
||||
restartPolicy: OnFailure
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Hera's Planner",
|
||||
"short_name": "Planner",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f2f5",
|
||||
"theme_color": "#667eea",
|
||||
"icons": [
|
||||
{ "src": "icon-180.png", "sizes": "180x180", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
BIN
notebook.jpg
Normal file
BIN
notebook.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 405 KiB |
449
schedule-tool.jsx
Normal file
449
schedule-tool.jsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const STORAGE_KEY = "mama-schedule-v1";
|
||||
|
||||
const WEEK_SCHEDULE = {
|
||||
周一: {
|
||||
fixed: [
|
||||
{ time: "5:45", task: "起床 · 深度工作 ⭐", type: "work" },
|
||||
{ time: "6:45", task: "孩子起床 · 做早饭", type: "family" },
|
||||
{ time: "8:00", task: "孩子自己上学 · 继续工作黄金时段", type: "work" },
|
||||
{ time: "12:00", task: "午饭", type: "life" },
|
||||
{ time: "13:00", task: "下午工作 + 家务30分钟", type: "work" },
|
||||
{ time: "15:30", task: "出门接孩子", type: "family" },
|
||||
{ time: "16:40", task: "接孩子回家", type: "family" },
|
||||
{ time: "17:30", task: "做晚饭", type: "life" },
|
||||
{ time: "19:00", task: "陪练琴 / 作业", type: "family" },
|
||||
{ time: "20:00", task: "陪读书", type: "family" },
|
||||
{ time: "20:30", task: "孩子入睡 · 轻量工作 + 日记", type: "work" },
|
||||
{ time: "22:00", task: "🌙 洗漱睡觉", type: "sleep" },
|
||||
],
|
||||
note: "最自由的一天,保护上午深度工作时间",
|
||||
},
|
||||
周二: {
|
||||
fixed: [
|
||||
{ time: "5:45", task: "起床 · 早晨个人时间 / 日记", type: "self" },
|
||||
{ time: "6:45", task: "孩子起床 · 做早饭", type: "family" },
|
||||
{ time: "7:40", task: "送孩子出门", type: "family" },
|
||||
{ time: "8:30", task: "去朋友家工作 ⭐", type: "work" },
|
||||
{ time: "15:30", task: "出门接孩子", type: "family" },
|
||||
{ time: "16:40", task: "回家 · 做晚饭", type: "life" },
|
||||
{ time: "19:00", task: "陪练琴 / 作业", type: "family" },
|
||||
{ time: "20:00", task: "陪读书", type: "family" },
|
||||
{ time: "20:30", task: "孩子入睡 · 轻量工作 + 回消息", type: "work" },
|
||||
{ time: "22:00", task: "🌙 洗漱睡觉", type: "sleep" },
|
||||
],
|
||||
note: "朋友家是深度工作最佳环境,珍惜这段时间",
|
||||
},
|
||||
周三: {
|
||||
fixed: [
|
||||
{ time: "5:45", task: "起床 · 轻量工作 / 回消息", type: "work" },
|
||||
{ time: "6:45", task: "孩子起床 · 做早饭", type: "family" },
|
||||
{ time: "7:40", task: "送孩子 → 直接去健身 🏋️", type: "self" },
|
||||
{ time: "10:30", task: "回家 · 洗澡", type: "self" },
|
||||
{ time: "11:00", task: "深度工作 ⭐", type: "work" },
|
||||
{ time: "12:30", task: "午饭", type: "life" },
|
||||
{ time: "13:00", task: "继续工作", type: "work" },
|
||||
{ time: "15:30", task: "出门接孩子", type: "family" },
|
||||
{ time: "16:40", task: "回家", type: "family" },
|
||||
{ time: "17:30", task: "做晚饭(简化)", type: "life" },
|
||||
{ time: "19:00", task: "陪练琴 / 作业", type: "family" },
|
||||
{ time: "20:00", task: "陪读书", type: "family" },
|
||||
{ time: "20:30", task: "孩子入睡 · 只写日记,不工作", type: "self" },
|
||||
{ time: "22:00", task: "🌙 洗漱睡觉", type: "sleep" },
|
||||
],
|
||||
note: "健身日晚上不安排工作,上午已补回来了",
|
||||
},
|
||||
周四: {
|
||||
fixed: [
|
||||
{ time: "5:45", task: "起床 · 深度工作 ⭐", type: "work" },
|
||||
{ time: "6:45", task: "孩子起床 · 做早饭", type: "family" },
|
||||
{ time: "7:40", task: "送孩子出门", type: "family" },
|
||||
{ time: "8:00", task: "回家 · 继续深度工作", type: "work" },
|
||||
{ time: "12:00", task: "午饭", type: "life" },
|
||||
{ time: "13:00", task: "工作 + 家务30分钟", type: "work" },
|
||||
{ time: "15:30", task: "出门接孩子", type: "family" },
|
||||
{ time: "16:40", task: "接孩子回家", type: "family" },
|
||||
{ time: "17:30", task: "做晚饭", type: "life" },
|
||||
{ time: "19:00", task: "陪练琴 / 作业", type: "family" },
|
||||
{ time: "20:00", task: "陪读书", type: "family" },
|
||||
{ time: "20:30", task: "孩子入睡 · 轻量工作 + 日记", type: "work" },
|
||||
{ time: "22:00", task: "🌙 洗漱睡觉", type: "sleep" },
|
||||
],
|
||||
note: "早晨是黄金深度工作时间,下午被接送切割",
|
||||
},
|
||||
周五: {
|
||||
fixed: [
|
||||
{ time: "5:45", task: "起床 · 轻量工作 / 回消息", type: "work" },
|
||||
{ time: "6:45", task: "孩子起床 · 做早饭", type: "family" },
|
||||
{ time: "7:40", task: "送孩子 → 直接去健身 🏋️", type: "self" },
|
||||
{ time: "10:30", task: "回家 · 洗澡", type: "self" },
|
||||
{ time: "11:00", task: "深度工作 ⭐", type: "work" },
|
||||
{ time: "12:30", task: "午饭", type: "life" },
|
||||
{ time: "13:00", task: "继续工作", type: "work" },
|
||||
{ time: "15:30", task: "出门接孩子", type: "family" },
|
||||
{ time: "16:40", task: "回家", type: "family" },
|
||||
{ time: "17:30", task: "做晚饭(简化)", type: "life" },
|
||||
{ time: "19:00", task: "陪练琴 / 作业", type: "family" },
|
||||
{ time: "20:00", task: "陪读书", type: "family" },
|
||||
{ time: "20:30", task: "孩子入睡 · 只写日记,不工作", type: "self" },
|
||||
{ time: "22:00", task: "🌙 洗漱睡觉", type: "sleep" },
|
||||
],
|
||||
note: "周五健身日,晚上放松,迎接周末",
|
||||
},
|
||||
};
|
||||
|
||||
const TYPE_STYLE = {
|
||||
work: { bg: "#e8f4fd", dot: "#3b82f6", label: "工作" },
|
||||
family: { bg: "#fef3c7", dot: "#f59e0b", label: "家庭" },
|
||||
self: { bg: "#f0fdf4", dot: "#22c55e", label: "自我" },
|
||||
life: { bg: "#fdf4ff", dot: "#a855f7", label: "生活" },
|
||||
sleep: { bg: "#f1f5f9", dot: "#64748b", label: "睡眠" },
|
||||
};
|
||||
|
||||
const DAYS = ["周一", "周二", "周三", "周四", "周五"];
|
||||
const WEEKS = ["第1-2周", "第3-4周", "第5-6周"];
|
||||
const SLEEP_TARGETS = { "第1-2周": "23:00前", "第3-4周": "22:30前", "第5-6周": "22:00前" };
|
||||
|
||||
function loadData() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveData(data) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState("today");
|
||||
const [selectedDay, setSelectedDay] = useState(DAYS[new Date().getDay() - 1] || "周一");
|
||||
const [data, setData] = useState(loadData);
|
||||
const [newTask, setNewTask] = useState({ text: "", type: "brain" });
|
||||
const [weekReview, setWeekReview] = useState({ sleep: "", wins: "", issues: "", next: "" });
|
||||
const [currentWeek, setCurrentWeek] = useState("第1-2周");
|
||||
const [showAddTask, setShowAddTask] = useState(false);
|
||||
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
const tasks = data[todayKey]?.tasks || [];
|
||||
const reviews = data.reviews || {};
|
||||
|
||||
function updateData(newData) {
|
||||
setData(newData);
|
||||
saveData(newData);
|
||||
}
|
||||
|
||||
function addTask() {
|
||||
if (!newTask.text.trim()) return;
|
||||
const updated = {
|
||||
...data,
|
||||
[todayKey]: {
|
||||
...data[todayKey],
|
||||
tasks: [...tasks, { id: Date.now(), text: newTask.text, type: newTask.type, done: false }],
|
||||
},
|
||||
};
|
||||
updateData(updated);
|
||||
setNewTask({ text: "", type: "brain" });
|
||||
setShowAddTask(false);
|
||||
}
|
||||
|
||||
function toggleTask(id) {
|
||||
const updated = {
|
||||
...data,
|
||||
[todayKey]: {
|
||||
...data[todayKey],
|
||||
tasks: tasks.map(t => t.id === id ? { ...t, done: !t.done } : t),
|
||||
},
|
||||
};
|
||||
updateData(updated);
|
||||
}
|
||||
|
||||
function deleteTask(id) {
|
||||
const updated = {
|
||||
...data,
|
||||
[todayKey]: {
|
||||
...data[todayKey],
|
||||
tasks: tasks.filter(t => t.id !== id),
|
||||
},
|
||||
};
|
||||
updateData(updated);
|
||||
}
|
||||
|
||||
function saveReview() {
|
||||
const weekKey = `${currentWeek}-${new Date().toISOString().slice(0, 7)}`;
|
||||
const updated = {
|
||||
...data,
|
||||
reviews: { ...reviews, [weekKey]: { ...weekReview, date: new Date().toLocaleDateString("zh-CN") } },
|
||||
};
|
||||
updateData(updated);
|
||||
setWeekReview({ sleep: "", wins: "", issues: "", next: "" });
|
||||
alert("回顾已保存 ✓");
|
||||
}
|
||||
|
||||
const brainTasks = tasks.filter(t => t.type === "brain");
|
||||
const lightTasks = tasks.filter(t => t.type === "light");
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "'Noto Serif SC', 'Georgia', serif", minHeight: "100vh", background: "#faf8f5", color: "#2c2c2c" }}>
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;500;600&display=swap');
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
textarea, input { font-family: inherit; }
|
||||
.tab-btn { background: none; border: none; padding: 10px 18px; font-size: 14px; color: #999; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
||||
.tab-btn.active { color: #2c2c2c; border-bottom-color: #2c2c2c; font-weight: 500; }
|
||||
.tab-btn:hover { color: #2c2c2c; }
|
||||
.day-btn { background: none; border: 1px solid #e0dbd4; border-radius: 20px; padding: 6px 14px; font-size: 13px; color: #666; transition: all 0.2s; }
|
||||
.day-btn.active { background: #2c2c2c; color: #fff; border-color: #2c2c2c; }
|
||||
.day-btn:hover:not(.active) { border-color: #999; color: #333; }
|
||||
.task-card { background: #fff; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid #ede8e1; display: flex; align-items: center; gap: 12px; transition: all 0.2s; }
|
||||
.task-card:hover { border-color: #ccc; }
|
||||
.task-card.done { opacity: 0.45; }
|
||||
.check-btn { width: 22px; height: 22px; border-radius: 50%; border: 2px solid #d0c9bf; background: none; flex-shrink: 0; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
|
||||
.check-btn.done { background: #2c2c2c; border-color: #2c2c2c; color: #fff; }
|
||||
.check-btn:hover { border-color: #2c2c2c; }
|
||||
.del-btn { background: none; border: none; color: #ccc; font-size: 16px; padding: 2px 6px; border-radius: 4px; margin-left: auto; flex-shrink: 0; }
|
||||
.del-btn:hover { color: #e55; background: #fff0f0; }
|
||||
.timeline-item { display: flex; gap: 14px; margin-bottom: 0; position: relative; }
|
||||
.timeline-item:not(:last-child)::before { content: ''; position: absolute; left: 36px; top: 28px; bottom: -2px; width: 1px; background: #ede8e1; }
|
||||
.add-btn { background: #2c2c2c; color: #fff; border: none; border-radius: 10px; padding: 10px 20px; font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.add-btn:hover { background: #444; }
|
||||
.input-field { border: 1px solid #e0dbd4; border-radius: 10px; padding: 10px 14px; font-size: 14px; width: 100%; outline: none; background: #fff; }
|
||||
.input-field:focus { border-color: #2c2c2c; }
|
||||
.select-field { border: 1px solid #e0dbd4; border-radius: 10px; padding: 10px 14px; font-size: 14px; background: #fff; outline: none; }
|
||||
.select-field:focus { border-color: #2c2c2c; }
|
||||
.section-title { font-size: 13px; font-weight: 500; color: #888; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.review-input { border: 1px solid #e0dbd4; border-radius: 10px; padding: 12px 14px; font-size: 14px; width: 100%; resize: none; outline: none; background: #fff; min-height: 70px; line-height: 1.6; }
|
||||
.review-input:focus { border-color: #2c2c2c; }
|
||||
.week-badge { display: inline-block; background: #f0ebe3; border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #666; }
|
||||
.progress-bar { height: 6px; background: #ede8e1; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #2c2c2c; border-radius: 3px; transition: width 0.5s; }
|
||||
.empty-state { text-align: center; padding: 30px 20px; color: #bbb; font-size: 14px; line-height: 1.8; }
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ background: "#fff", borderBottom: "1px solid #ede8e1", padding: "20px 20px 0" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "#aaa", letterSpacing: "0.1em", marginBottom: 4 }}>我的时间管理</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, letterSpacing: "-0.02em" }}>早睡计划</h1>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 4 }}>目标睡眠时间</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: "#2c7a2c" }}>{SLEEP_TARGETS[currentWeek]}</div>
|
||||
<select value={currentWeek} onChange={e => setCurrentWeek(e.target.value)} style={{ marginTop: 4, border: "1px solid #e0dbd4", borderRadius: 6, padding: "3px 8px", fontSize: 11, color: "#666", background: "#fff", cursor: "pointer" }}>
|
||||
{WEEKS.map(w => <option key={w}>{w}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6周进度 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#aaa", marginBottom: 6 }}>
|
||||
<span>6周养成计划</span>
|
||||
<span>{currentWeek}</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: currentWeek === "第1-2周" ? "33%" : currentWeek === "第3-4周" ? "66%" : "100%" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{[["today", "今日任务"], ["schedule", "日程表"], ["review", "周回顾"]].map(([key, label]) => (
|
||||
<button key={key} className={`tab-btn ${tab === key ? "active" : ""}`} onClick={() => setTab(key)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "20px 20px 40px", maxWidth: 600, margin: "0 auto" }}>
|
||||
|
||||
{/* ===== 今日任务 ===== */}
|
||||
{tab === "today" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>{new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })}</div>
|
||||
<div style={{ fontSize: 12, color: "#aaa", marginTop: 2 }}>记录今天要处理的事情</div>
|
||||
</div>
|
||||
<button className="add-btn" onClick={() => setShowAddTask(!showAddTask)}>
|
||||
<span style={{ fontSize: 18, lineHeight: 1 }}>+</span> 添加任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddTask && (
|
||||
<div style={{ background: "#fff", border: "1px solid #ede8e1", borderRadius: 14, padding: 16, marginBottom: 20 }}>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="任务内容..."
|
||||
value={newTask.text}
|
||||
onChange={e => setNewTask({ ...newTask, text: e.target.value })}
|
||||
onKeyDown={e => e.key === "Enter" && addTask()}
|
||||
style={{ marginBottom: 10 }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<select className="select-field" value={newTask.type} onChange={e => setNewTask({ ...newTask, type: e.target.value })}>
|
||||
<option value="brain">🧠 需要脑力</option>
|
||||
<option value="light">✅ 不需要思考</option>
|
||||
</select>
|
||||
<button onClick={addTask} style={{ background: "#2c2c2c", color: "#fff", border: "none", borderRadius: 10, padding: "10px 18px", fontSize: 14, whiteSpace: "nowrap" }}>确认</button>
|
||||
<button onClick={() => setShowAddTask(false)} style={{ background: "none", border: "1px solid #e0dbd4", borderRadius: 10, padding: "10px 14px", fontSize: 14, color: "#666" }}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 脑力任务 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>🧠</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>需要脑力的任务</div>
|
||||
<div style={{ fontSize: 11, color: "#aaa" }}>安排在早晨 5:45 或上午时段</div>
|
||||
</div>
|
||||
</div>
|
||||
{brainTasks.length === 0 ? (
|
||||
<div className="empty-state">还没有脑力任务<br />点击「添加任务」→ 选择「需要脑力」</div>
|
||||
) : brainTasks.map(t => (
|
||||
<div key={t.id} className={`task-card ${t.done ? "done" : ""}`}>
|
||||
<button className={`check-btn ${t.done ? "done" : ""}`} onClick={() => toggleTask(t.id)}>
|
||||
{t.done && <span style={{ fontSize: 11 }}>✓</span>}
|
||||
</button>
|
||||
<span style={{ fontSize: 14, flex: 1, textDecoration: t.done ? "line-through" : "none" }}>{t.text}</span>
|
||||
<button className="del-btn" onClick={() => deleteTask(t.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 轻量任务 */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>✅</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>不需要思考的任务</div>
|
||||
<div style={{ fontSize: 11, color: "#aaa" }}>留到晚上 20:30 后处理</div>
|
||||
</div>
|
||||
</div>
|
||||
{lightTasks.length === 0 ? (
|
||||
<div className="empty-state">还没有轻量任务<br />点击「添加任务」→ 选择「不需要思考」</div>
|
||||
) : lightTasks.map(t => (
|
||||
<div key={t.id} className={`task-card ${t.done ? "done" : ""}`}>
|
||||
<button className={`check-btn ${t.done ? "done" : ""}`} onClick={() => toggleTask(t.id)}>
|
||||
{t.done && <span style={{ fontSize: 11 }}>✓</span>}
|
||||
</button>
|
||||
<span style={{ fontSize: 14, flex: 1, textDecoration: t.done ? "line-through" : "none" }}>{t.text}</span>
|
||||
<button className="del-btn" onClick={() => deleteTask(t.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<div style={{ marginTop: 20, background: "#fff", borderRadius: 12, padding: "12px 16px", border: "1px solid #ede8e1" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, color: "#666" }}>
|
||||
<span>完成进度</span>
|
||||
<span>{tasks.filter(t => t.done).length} / {tasks.length} 件</span>
|
||||
</div>
|
||||
<div className="progress-bar" style={{ marginTop: 8 }}>
|
||||
<div className="progress-fill" style={{ width: `${tasks.length ? (tasks.filter(t => t.done).length / tasks.length * 100) : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 日程表 ===== */}
|
||||
{tab === "schedule" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20, flexWrap: "wrap" }}>
|
||||
{DAYS.map(d => (
|
||||
<button key={d} className={`day-btn ${selectedDay === d ? "active" : ""}`} onClick={() => setSelectedDay(d)}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{WEEK_SCHEDULE[selectedDay] && (
|
||||
<>
|
||||
<div style={{ background: "#fff8e7", border: "1px solid #f5e6bb", borderRadius: 10, padding: "10px 14px", marginBottom: 20, fontSize: 13, color: "#8a6c00" }}>
|
||||
💡 {WEEK_SCHEDULE[selectedDay].note}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{WEEK_SCHEDULE[selectedDay].fixed.map((item, i) => {
|
||||
const style = TYPE_STYLE[item.type];
|
||||
return (
|
||||
<div key={i} className="timeline-item" style={{ padding: "4px 0" }}>
|
||||
<div style={{ width: 58, flexShrink: 0, fontSize: 12, color: "#999", paddingTop: 8, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{item.time}</div>
|
||||
<div style={{ width: 14, flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 10 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: "50%", background: style.dot, flexShrink: 0 }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, background: item.type === "sleep" ? "#f1f5f9" : "#fff", border: `1px solid ${item.type === "sleep" ? "#cbd5e1" : "#ede8e1"}`, borderRadius: 10, padding: "8px 12px", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: item.type === "sleep" ? 500 : 400 }}>{item.task}</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 10, color: style.dot, background: style.bg, padding: "2px 6px", borderRadius: 4 }}>{style.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 周回顾 ===== */}
|
||||
{tab === "review" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>每周回顾</div>
|
||||
<div style={{ fontSize: 12, color: "#aaa", marginTop: 2 }}>每周花10分钟复盘,调整下周计划</div>
|
||||
</div>
|
||||
<span className="week-badge">{currentWeek}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{[
|
||||
{ key: "sleep", label: "😴 这周睡眠情况", placeholder: "实际几点睡的?有没有改善?" },
|
||||
{ key: "wins", label: "✨ 做得好的地方", placeholder: "哪些时间安排运转良好?" },
|
||||
{ key: "issues", label: "🔧 遇到的问题", placeholder: "哪里卡住了?什么原因?" },
|
||||
{ key: "next", label: "🎯 下周调整计划", placeholder: "下周想改变什么?具体怎么做?" },
|
||||
].map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 8 }}>{label}</div>
|
||||
<textarea
|
||||
className="review-input"
|
||||
placeholder={placeholder}
|
||||
value={weekReview[key]}
|
||||
onChange={e => setWeekReview({ ...weekReview, [key]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={saveReview} style={{ background: "#2c2c2c", color: "#fff", border: "none", borderRadius: 12, padding: "14px", fontSize: 15, fontFamily: "inherit", marginTop: 4 }}>
|
||||
保存本周回顾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 历史回顾 */}
|
||||
{Object.keys(reviews).length > 0 && (
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<div className="section-title">历史回顾</div>
|
||||
{Object.entries(reviews).reverse().map(([key, r]) => (
|
||||
<div key={key} style={{ background: "#fff", border: "1px solid #ede8e1", borderRadius: 12, padding: 16, marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<span className="week-badge">{key.split("-")[0]}{key.split("-")[1] ? "-" + key.split("-")[1] : ""}</span>
|
||||
<span style={{ fontSize: 12, color: "#aaa" }}>{r.date}</span>
|
||||
</div>
|
||||
{r.sleep && <div style={{ fontSize: 13, marginBottom: 6 }}><span style={{ color: "#aaa" }}>睡眠:</span>{r.sleep}</div>}
|
||||
{r.wins && <div style={{ fontSize: 13, marginBottom: 6 }}><span style={{ color: "#aaa" }}>亮点:</span>{r.wins}</div>}
|
||||
{r.issues && <div style={{ fontSize: 13, marginBottom: 6 }}><span style={{ color: "#aaa" }}>问题:</span>{r.issues}</div>}
|
||||
{r.next && <div style={{ fontSize: 13 }}><span style={{ color: "#aaa" }}>下周:</span>{r.next}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
399
server.py
Normal file
399
server.py
Normal file
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hera Planner - 数据持久化服务器"""
|
||||
import json, os, shutil, hashlib, time
|
||||
from datetime import datetime
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/data')
|
||||
STATIC_DIR = os.environ.get('STATIC_DIR', '/app/static')
|
||||
BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
|
||||
PASS_FILE = os.path.join(DATA_DIR, 'password.txt')
|
||||
BUDDY_FILE = os.path.join(DATA_DIR, 'sleep_buddy.json')
|
||||
BUDDY_PASS_FILE = os.path.join(DATA_DIR, 'buddy_password.txt')
|
||||
|
||||
DEFAULT_HASH = '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'
|
||||
|
||||
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DATA_FILE = os.path.join(DATA_DIR, 'planner_data.json')
|
||||
|
||||
def get_pass_hash():
|
||||
if os.path.exists(PASS_FILE): return open(PASS_FILE).read().strip()
|
||||
return DEFAULT_HASH
|
||||
|
||||
def set_pass_hash(h):
|
||||
with open(PASS_FILE, 'w') as f: f.write(h)
|
||||
|
||||
def get_buddy_pass():
|
||||
if os.path.exists(BUDDY_PASS_FILE): return open(BUDDY_PASS_FILE).read().strip()
|
||||
return DEFAULT_HASH
|
||||
|
||||
def set_buddy_pass(h):
|
||||
with open(BUDDY_PASS_FILE, 'w') as f: f.write(h)
|
||||
|
||||
def load_data():
|
||||
if os.path.exists(DATA_FILE):
|
||||
with open(DATA_FILE, 'r', encoding='utf-8') as f: return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_data(data):
|
||||
tmp = DATA_FILE + '.tmp'
|
||||
with open(tmp, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, DATA_FILE)
|
||||
|
||||
def load_buddy():
|
||||
if os.path.exists(BUDDY_FILE):
|
||||
with open(BUDDY_FILE, 'r', encoding='utf-8') as f: return json.load(f)
|
||||
return {'users': {}, 'notifications': []}
|
||||
|
||||
def save_buddy(data):
|
||||
tmp = BUDDY_FILE + '.tmp'
|
||||
with open(tmp, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, BUDDY_FILE)
|
||||
|
||||
def merge_protected(existing, incoming):
|
||||
"""服务器端数据保护:所有重要数据都做合并,防止多设备覆盖丢失"""
|
||||
# 按 id 合并:sp_notes, sp_bugs, sp_goals, sp_checklists, sp_custom_reminders
|
||||
# 按日期合并:sp_sleep
|
||||
# 深度合并:sp_health_checks, sp_music_checks, sp_health_plans, sp_music_plans
|
||||
# 嵌套合并:sp_todos (每个象限按 id),sp_docs (按 doc id + entries)
|
||||
# 不合并(last-write-wins):sp_inbox, sp_reviews, sp_modules, sp_schedule 等简单配置
|
||||
ARRAY_KEYS_BY_ID = ['sp_notes', 'sp_bugs', 'sp_goals', 'sp_checklists', 'sp_custom_reminders', 'sp_gym', 'sp_period'] # 按 id 合并
|
||||
ARRAY_KEYS_BY_DATE = ['sp_sleep'] # 睡眠按日期合并
|
||||
OBJECT_MERGE_KEYS = ['sp_health_checks', 'sp_music_checks', 'sp_health_plans', 'sp_music_plans'] # 打卡数据深度合并
|
||||
# sp_todos 是 {q1:[],q2:[],q3:[],q4:[]},需要按 id 合并每个象限
|
||||
NESTED_ARRAY_KEYS = ['sp_todos'] # 对象中的数组按 id 合并
|
||||
|
||||
for key in ARRAY_KEYS_BY_ID:
|
||||
if key in existing and key in incoming:
|
||||
try:
|
||||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||||
if isinstance(server, list) and isinstance(client, list):
|
||||
# 检查删除标记
|
||||
deleted_key = key + '_deleted'
|
||||
deleted_ids = set()
|
||||
if deleted_key in incoming:
|
||||
try: deleted_ids = set(json.loads(incoming[deleted_key]))
|
||||
except: pass
|
||||
# 按 id 合并,客户端优先,排除已删除的
|
||||
merged = {r.get('id', i): r for i, r in enumerate(server) if r.get('id') not in deleted_ids}
|
||||
for i, r in enumerate(client):
|
||||
merged[r.get('id', f'c_{i}')] = r
|
||||
incoming[key] = json.dumps(list(merged.values()))
|
||||
except: pass
|
||||
|
||||
for key in ARRAY_KEYS_BY_DATE:
|
||||
if key in existing and key in incoming:
|
||||
try:
|
||||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||||
if isinstance(server, list) and isinstance(client, list):
|
||||
# 检查是否有删除标记
|
||||
deleted_key = key + '_deleted'
|
||||
deleted_dates = set()
|
||||
if deleted_key in incoming:
|
||||
try: deleted_dates = set(json.loads(incoming[deleted_key]))
|
||||
except: pass
|
||||
merged = {r['date']: r for r in server if r['date'] not in deleted_dates}
|
||||
for r in client:
|
||||
merged[r['date']] = r
|
||||
incoming[key] = json.dumps(sorted(merged.values(), key=lambda x: x.get('date',''), reverse=True))
|
||||
except: pass
|
||||
|
||||
for key in OBJECT_MERGE_KEYS:
|
||||
if key in existing and key in incoming:
|
||||
try:
|
||||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||||
if isinstance(server, dict) and isinstance(client, dict):
|
||||
# 深度合并:对于数组值按 id 合并,对于对象值合并 key
|
||||
merged = dict(server)
|
||||
for k, v in client.items():
|
||||
if isinstance(v, list) and isinstance(merged.get(k), list):
|
||||
m = {r.get('id', i): r for i, r in enumerate(merged[k])}
|
||||
for i, r in enumerate(v):
|
||||
m[r.get('id', f'c_{i}')] = r
|
||||
merged[k] = list(m.values())
|
||||
elif isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||||
merged[k] = {**merged[k], **v}
|
||||
else:
|
||||
merged[k] = v
|
||||
incoming[key] = json.dumps(merged)
|
||||
except: pass
|
||||
|
||||
# sp_todos: {q1:[...], q2:[...], ...} — 每个象限的数组按 id 合并
|
||||
for key in NESTED_ARRAY_KEYS:
|
||||
if key in existing and key in incoming:
|
||||
try:
|
||||
server = json.loads(existing[key]) if isinstance(existing[key], str) else existing[key]
|
||||
client = json.loads(incoming[key]) if isinstance(incoming[key], str) else incoming[key]
|
||||
if isinstance(server, dict) and isinstance(client, dict):
|
||||
merged = {}
|
||||
all_keys = set(list(server.keys()) + list(client.keys()))
|
||||
for k in all_keys:
|
||||
sv = server.get(k, [])
|
||||
cv = client.get(k, [])
|
||||
if isinstance(sv, list) and isinstance(cv, list):
|
||||
m = {r.get('id', i): r for i, r in enumerate(sv)}
|
||||
for i, r in enumerate(cv):
|
||||
m[r.get('id', f'c_{i}')] = r
|
||||
merged[k] = list(m.values())
|
||||
else:
|
||||
merged[k] = cv if cv else sv
|
||||
incoming[key] = json.dumps(merged)
|
||||
except: pass
|
||||
|
||||
# sp_docs: 按 doc id 合并,每个 doc 的 entries 也按内容合并
|
||||
if 'sp_docs' in existing and 'sp_docs' in incoming:
|
||||
try:
|
||||
server_docs = json.loads(existing['sp_docs']) if isinstance(existing['sp_docs'], str) else existing['sp_docs']
|
||||
client_docs = json.loads(incoming['sp_docs']) if isinstance(incoming['sp_docs'], str) else incoming['sp_docs']
|
||||
if isinstance(server_docs, list) and isinstance(client_docs, list):
|
||||
merged = {d.get('id', d.get('name', i)): d for i, d in enumerate(server_docs)}
|
||||
for i, d in enumerate(client_docs):
|
||||
did = d.get('id', d.get('name', f'c_{i}'))
|
||||
if did in merged:
|
||||
# 合并 entries:客户端优先,但保留服务器独有的
|
||||
se = merged[did].get('entries', [])
|
||||
ce = d.get('entries', [])
|
||||
entry_map = {}
|
||||
for e in se:
|
||||
eid = e.get('noteId', '') or e.get('text', '')
|
||||
entry_map[eid] = e
|
||||
for e in ce:
|
||||
eid = e.get('noteId', '') or e.get('text', '')
|
||||
entry_map[eid] = e
|
||||
d['entries'] = list(entry_map.values())
|
||||
merged[did] = d
|
||||
incoming['sp_docs'] = json.dumps(list(merged.values()))
|
||||
except: pass
|
||||
|
||||
return incoming
|
||||
|
||||
def do_backup():
|
||||
if not os.path.exists(DATA_FILE): return
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
shutil.copy2(DATA_FILE, os.path.join(BACKUP_DIR, f'planner_{ts}.json'))
|
||||
for old in sorted(Path(BACKUP_DIR).glob('planner_*.json'))[:-30]: old.unlink()
|
||||
|
||||
def json_response(handler, code, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
||||
handler.send_response(code)
|
||||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
handler.send_header('Content-Length', len(body))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
def read_body(handler):
|
||||
length = int(handler.headers.get('Content-Length', 0))
|
||||
return json.loads(handler.rfile.read(length))
|
||||
|
||||
def no_cache_html(handler, fpath):
|
||||
with open(fpath, 'rb') as f: content = f.read()
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
handler.send_header('Content-Length', len(content))
|
||||
handler.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0')
|
||||
handler.send_header('Pragma', 'no-cache')
|
||||
handler.send_header('Expires', '0')
|
||||
handler.end_headers()
|
||||
handler.wfile.write(content)
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=STATIC_DIR, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/api/data':
|
||||
json_response(self, 200, load_data())
|
||||
elif self.path == '/api/backups':
|
||||
backups = sorted(Path(BACKUP_DIR).glob('planner_*.json'), reverse=True)
|
||||
items = [{'name': b.name, 'size': b.stat().st_size, 'time': b.stem.split('_',1)[1]} for b in backups[:20]]
|
||||
json_response(self, 200, items)
|
||||
elif self.path == '/api/sleep-buddy':
|
||||
# 只返回睡眠数据,不返回其他任何数据
|
||||
json_response(self, 200, load_buddy())
|
||||
elif self.path == '/api/version':
|
||||
# 返回 HTML 文件的修改时间戳,用于客户端自动刷新
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(STATIC_DIR, 'index.html'))
|
||||
json_response(self, 200, {'v': str(int(mtime))})
|
||||
except: json_response(self, 200, {'v': '0'})
|
||||
elif self.path in ('/', '/index.html'):
|
||||
no_cache_html(self, os.path.join(STATIC_DIR, 'index.html'))
|
||||
elif self.path in ('/sleep', '/sleep-buddy', '/sleep-buddy.html'):
|
||||
fpath = os.path.join(STATIC_DIR, 'sleep-buddy.html')
|
||||
if os.path.exists(fpath): no_cache_html(self, fpath)
|
||||
else: self.send_response(404); self.end_headers()
|
||||
else:
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/api/login':
|
||||
try:
|
||||
body = read_body(self)
|
||||
if body.get('hash', '') == get_pass_hash():
|
||||
json_response(self, 200, {'ok': True})
|
||||
else:
|
||||
json_response(self, 401, {'ok': False, 'error': '密码不正确'})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
elif self.path == '/api/change-password':
|
||||
try:
|
||||
body = read_body(self)
|
||||
if body.get('oldHash', '') != get_pass_hash():
|
||||
json_response(self, 401, {'ok': False, 'error': '当前密码不正确'})
|
||||
else:
|
||||
set_pass_hash(body.get('newHash', ''))
|
||||
json_response(self, 200, {'ok': True})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
elif self.path == '/api/data':
|
||||
try:
|
||||
data = read_body(self)
|
||||
data.pop('sp_pass_hash', None)
|
||||
existing = load_data()
|
||||
|
||||
# 服务器端数据保护:合并所有数组类型的数据,防止设备推送不完整数据导致丢失
|
||||
# 对于客户端没有的 key,保留服务器已有的(新设备不会冲掉旧数据)
|
||||
if existing:
|
||||
for k, v in existing.items():
|
||||
if k.startswith('sp_') and k not in data:
|
||||
data[k] = v # 客户端没这个 key,保留服务器的
|
||||
data = merge_protected(existing, data)
|
||||
save_data(data)
|
||||
do_backup()
|
||||
json_response(self, 200, {'ok': True})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
# ===== Sleep Buddy API(完全独立,不接触主数据) =====
|
||||
elif self.path == '/api/buddy-register':
|
||||
try:
|
||||
body = read_body(self)
|
||||
username = body.get('username', '').strip()
|
||||
pw_hash = body.get('hash', '')
|
||||
if not username or not pw_hash:
|
||||
json_response(self, 400, {'ok': False, 'error': '请输入用户名和密码'})
|
||||
else:
|
||||
buddy = load_buddy()
|
||||
accounts = buddy.get('accounts', {})
|
||||
if username in accounts:
|
||||
json_response(self, 400, {'ok': False, 'error': '用户名已存在'})
|
||||
else:
|
||||
accounts[username] = {'hash': pw_hash}
|
||||
buddy['accounts'] = accounts
|
||||
if username not in buddy.get('users', {}):
|
||||
buddy.setdefault('users', {})[username] = []
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
elif self.path == '/api/buddy-delete-user':
|
||||
# 只允许通过 Planner 密码删除(管理员操作)
|
||||
try:
|
||||
body = read_body(self)
|
||||
admin_hash = body.get('adminHash', '')
|
||||
username = body.get('username', '').strip()
|
||||
if admin_hash != get_pass_hash():
|
||||
json_response(self, 401, {'ok': False, 'error': '需要管理员密码'})
|
||||
else:
|
||||
buddy = load_buddy()
|
||||
buddy.get('accounts', {}).pop(username, None)
|
||||
buddy.get('users', {}).pop(username, None)
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
elif self.path == '/api/buddy-login':
|
||||
try:
|
||||
body = read_body(self)
|
||||
username = body.get('username', '').strip()
|
||||
pw_hash = body.get('hash', '')
|
||||
buddy = load_buddy()
|
||||
accounts = buddy.get('accounts', {})
|
||||
if username in accounts and accounts[username].get('hash') == pw_hash:
|
||||
json_response(self, 200, {'ok': True, 'username': username})
|
||||
else:
|
||||
json_response(self, 401, {'ok': False, 'error': '用户名或密码不正确'})
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
|
||||
elif self.path == '/api/sleep-buddy':
|
||||
try:
|
||||
body = read_body(self)
|
||||
buddy = load_buddy()
|
||||
user = body.get('user', '')
|
||||
action = body.get('action', '')
|
||||
|
||||
if action == 'record':
|
||||
# 记录睡眠
|
||||
if user not in buddy['users']: buddy['users'][user] = []
|
||||
record = body.get('record', {})
|
||||
# 去重
|
||||
buddy['users'][user] = [r for r in buddy['users'][user] if r.get('date') != record.get('date')]
|
||||
buddy['users'][user].append(record)
|
||||
buddy['users'][user].sort(key=lambda x: x.get('date',''), reverse=True)
|
||||
# 只保留最近60天
|
||||
buddy['users'][user] = buddy['users'][user][:60]
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
|
||||
elif action == 'delete-record':
|
||||
date = body.get('date', '')
|
||||
if user in buddy.get('users', {}):
|
||||
buddy['users'][user] = [r for r in buddy['users'][user] if r.get('date') != date]
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
|
||||
elif action == 'sleep-now':
|
||||
# 标记去睡觉,通知其他人
|
||||
buddy['notifications'].append({
|
||||
'from': user,
|
||||
'time': datetime.now().strftime('%H:%M'),
|
||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
||||
'msg': f'{user} 去睡觉啦,你也早点休息!',
|
||||
'ts': time.time(),
|
||||
})
|
||||
# 只保留最近20条通知
|
||||
buddy['notifications'] = buddy['notifications'][-20:]
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
|
||||
elif action == 'get-notifications':
|
||||
# 获取未读通知(不是自己发的,最近24小时内的)
|
||||
cutoff = time.time() - 86400
|
||||
notifs = [n for n in buddy.get('notifications', [])
|
||||
if n.get('from') != user and n.get('ts', 0) > cutoff]
|
||||
json_response(self, 200, {'notifications': notifs})
|
||||
|
||||
elif action == 'set-target':
|
||||
target = body.get('target', '22:00')
|
||||
buddy.setdefault('targets', {})[user] = target
|
||||
save_buddy(buddy)
|
||||
json_response(self, 200, {'ok': True})
|
||||
|
||||
else:
|
||||
json_response(self, 200, buddy)
|
||||
except Exception as e:
|
||||
json_response(self, 500, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
if '/api/' in (args[0] if args else ''):
|
||||
print(f"[{datetime.now():%H:%M:%S}] {args[0]}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', '8080'))
|
||||
server = HTTPServer(('0.0.0.0', port), Handler)
|
||||
print(f"Planner server running on port {port}")
|
||||
server.serve_forever()
|
||||
623
sleep-buddy.html
Normal file
623
sleep-buddy.html
Normal file
@@ -0,0 +1,623 @@
|
||||
<!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>
|
||||
27
sw.js
Normal file
27
sw.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Service Worker for Hera's Planner — 后台提醒通知
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', () => self.clients.claim());
|
||||
|
||||
// 接收主页面发来的通知请求
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
|
||||
self.registration.showNotification(event.data.title, {
|
||||
body: event.data.body,
|
||||
icon: 'icon-180.png',
|
||||
badge: 'icon-180.png',
|
||||
requireInteraction: true,
|
||||
tag: event.data.tag || 'planner-reminder',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 点击通知时打开页面
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then(clients => {
|
||||
if (clients.length > 0) { clients[0].focus(); }
|
||||
else { self.clients.openWindow('/'); }
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user