Initial commit: Schedule Planner

This commit is contained in:
2026-04-06 13:46:31 +00:00
commit b09cefad34
14 changed files with 9534 additions and 0 deletions

7
Dockerfile Normal file
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// 自动调整 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

26
icon-180.svg Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

51
k8s-backup-cronjob.yaml Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

449
schedule-tool.jsx Normal file
View 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
View 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-winssp_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
View 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
View 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('/'); }
})
);
});