Files
schedule-planner/index.html
2026-04-06 13:46:31 +00:00

7609 lines
297 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Hera's Planner</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">
<meta name="apple-mobile-web-app-title" content="Planner">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
color: #333;
min-height: 100vh;
}
/* ===== Login ===== */
.login-overlay {
position: fixed; inset: 0; z-index: 9999;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex; align-items: center; justify-content: center;
flex-direction: column;
}
.login-overlay.hidden { display: none; }
.login-banner {
position: absolute; inset: 0;
overflow: hidden; pointer-events: none;
}
.login-banner .circle {
position: absolute; border-radius: 50%;
background: rgba(255,255,255,0.06);
}
.login-banner .c1 { width: 400px; height: 400px; top: -100px; right: -80px; }
.login-banner .c2 { width: 250px; height: 250px; bottom: -60px; left: -40px; }
.login-banner .c3 { width: 150px; height: 150px; top: 40%; left: 60%; }
.login-card {
position: relative; z-index: 1;
text-align: center; color: white;
padding: 40px;
}
.login-title {
font-size: 32px; font-weight: 700;
margin-bottom: 6px;
letter-spacing: -0.5px;
}
.login-subtitle {
font-size: 14px; color: rgba(255,255,255,0.6);
margin-bottom: 32px;
}
.login-input-wrap {
display: flex; gap: 10px; justify-content: center;
}
.login-input {
padding: 12px 20px;
border: 2px solid rgba(255,255,255,0.25);
border-radius: 14px;
background: rgba(255,255,255,0.1);
color: white; font-size: 16px;
outline: none; width: 220px;
backdrop-filter: blur(8px);
transition: border-color 0.2s;
}
.login-input:focus { border-color: rgba(255,255,255,0.6); }
.login-input::placeholder { color: rgba(255,255,255,0.4); }
.login-btn {
padding: 12px 28px;
border: none; border-radius: 14px;
background: white; color: #667eea;
font-size: 16px; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.login-btn:hover { transform: scale(1.03); box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
.login-error {
color: #fca5a5; font-size: 13px;
margin-top: 12px; min-height: 20px;
}
/* ===== Header ===== */
header {
position: sticky;
top: 0;
z-index: 100;
background: url('notebook.jpg') center/cover no-repeat;
}
.header-main {
padding: 14px 24px;
position: relative;
background: rgba(255,255,255,0.3);
backdrop-filter: blur(1px);
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 1;
}
header h1 {
font-size: 20px; font-weight: 700;
color: #3a2a1a;
letter-spacing: -0.3px;
text-shadow: 0 1px 2px rgba(255,255,255,0.3);
}
.header-subtitle {
font-size: 11px;
color: #6b5a48;
margin-left: 10px;
font-weight: 400;
}
.header-actions { position: relative; }
.header-menu-btn {
width: 36px; height: 36px;
border: none; border-radius: 8px;
background: transparent;
color: #5a4a38; font-size: 20px;
cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; justify-content: center;
}
.header-menu-btn:hover { background: rgba(0,0,0,0.06); }
.header-dropdown {
display: none;
position: fixed;
top: 48px; right: 12px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
min-width: 160px;
overflow: hidden;
z-index: 10001;
}
.header-dropdown.open { display: block; }
.dropdown-mask {
display: none;
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0,0,0,0.1);
}
.dropdown-mask.open { display: block; }
.header-dropdown button {
display: block; width: 100%;
padding: 12px 18px;
border: none; background: none;
text-align: left; font-size: 14px;
color: #555; cursor: pointer;
transition: background 0.1s;
}
.header-dropdown button:hover { background: #f5f5f5; }
.header-dropdown .dd-danger { color: #ef4444; }
.header-dropdown .dd-danger:hover { background: #fef2f2; }
.btn {
padding: 8px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-light { background: #f5f5f5; color: #888; }
.btn-light:hover { background: #eee; color: #555; }
.btn-primary { background: #fff; color: #667eea; }
.btn-primary:hover { background: #f0f0ff; }
.btn-close { background: #eee; color: #666; }
.btn-close:hover { background: #ddd; }
.btn-accent { background: #667eea; color: white; }
.btn-accent:hover { background: #5a6fd6; }
/* ===== Tabs ===== */
.tabs {
display: flex;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
position: relative;
border: none;
padding: 0 16px;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs::before {
content: ''; position: absolute; inset: 0;
background: rgba(255,255,255,0.1);
pointer-events: none;
}
.tab-btn {
padding: 6px 12px;
margin: 6px 3px;
background: rgba(255,255,255,0.45);
border: none;
border-radius: 8px;
color: #6b5a48;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
font-weight: 500;
cursor: pointer;
border-bottom: none;
transition: all 0.2s;
position: relative;
z-index: 1;
}
.tab-btn:hover { background: rgba(255,255,255,0.65); color: #4a3a28; }
.tab-btn.active {
background: rgba(255,255,255,0.75);
color: #3a2a1a;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ===== 日程规划 Tab ===== */
.schedule-layout {
display: flex;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.module-pool {
width: 260px;
flex-shrink: 0;
position: sticky;
top: 24px;
align-self: flex-start;
}
.pool-card {
background: white;
border-radius: 14px;
padding: 18px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.pool-card h2 {
font-size: 15px;
color: #888;
margin-bottom: 14px;
font-weight: 500;
}
.module-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
cursor: grab;
transition: all 0.2s;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
user-select: none;
position: relative;
}
.module-item:active { cursor: grabbing; }
.module-item:hover { transform: translateX(4px); box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.module-item .emoji { font-size: 18px; }
.module-item .module-actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 4px;
align-items: center;
}
.module-item:hover .module-actions { display: flex; }
.module-item .module-actions button {
width: 20px; height: 20px;
border-radius: 50%; border: none;
background: rgba(0,0,0,0.1); color: #999;
font-size: 11px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.module-item .module-actions button:hover { background: rgba(0,0,0,0.2); color: #666; }
.module-item .module-actions .delete-module:hover { background: #ff4757; color: white; }
.add-module-row { display: flex; gap: 6px; margin-top: 12px; }
.add-module-row input {
flex: 1; padding: 8px 12px;
border: 1.5px solid #e0e0e0; border-radius: 8px;
font-size: 13px; outline: none;
}
.add-module-row input:focus { border-color: #667eea; }
.add-module-row button {
padding: 8px 12px; background: #667eea; color: white;
border: none; border-radius: 8px; cursor: pointer; font-size: 16px;
}
.color-picker-row { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.color-dot {
width: 22px; height: 22px; border-radius: 50%;
cursor: pointer; border: 2.5px solid transparent; transition: all 0.15s;
}
.color-dot:hover { transform: scale(1.15); }
.timeline { flex: 1; min-width: 0; }
.date-nav { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.date-nav button:not(.btn) {
width: 34px; height: 34px; border-radius: 50%; border: none;
background: white; cursor: pointer; font-size: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08); transition: all 0.2s;
}
.date-nav button:not(.btn):hover { background: #667eea; color: white; }
.date-nav .date-label { font-size: 18px; font-weight: 600; color: #444; }
.time-slot { display: flex; gap: 0; margin-bottom: 4px; min-height: 64px; }
.time-label {
width: 64px; flex-shrink: 0; padding-top: 10px;
font-size: 13px; font-weight: 600; color: #999;
text-align: right; padding-right: 14px;
}
.slot-drop {
flex: 1; background: white; border-radius: 10px;
min-height: 56px; padding: 6px 10px;
display: flex; flex-wrap: wrap; gap: 6px;
align-items: flex-start; align-content: flex-start;
transition: all 0.2s;
border: 2px solid transparent;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.slot-drop.drag-over {
border-color: #667eea; background: #f0f0ff;
box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
}
.slot-drop .placed-item {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 8px;
font-size: 13px; font-weight: 500;
cursor: grab; user-select: none; position: relative;
}
.placed-item:hover { filter: brightness(0.95); }
.placed-item .remove-btn {
width: 16px; height: 16px; border-radius: 50%; border: none;
background: rgba(0,0,0,0.15); color: white;
font-size: 10px; cursor: pointer;
display: none; align-items: center; justify-content: center;
margin-left: 2px;
}
.placed-item:hover .remove-btn { display: flex; }
/* ===== 每周模板 Tab ===== */
.template-layout {
max-width: 700px;
margin: 0 auto;
padding: 24px;
}
.day-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.day-btn {
padding: 8px 18px; border-radius: 20px;
border: 1.5px solid #ddd; background: white;
font-size: 13px; cursor: pointer; transition: all 0.2s;
}
.day-btn:hover { border-color: #667eea; color: #667eea; }
.day-btn.active { background: #667eea; color: white; border-color: #667eea; }
.day-note {
background: #fff8e7; border: 1px solid #f5e6bb;
border-radius: 10px; padding: 10px 14px;
margin-bottom: 16px; font-size: 13px; color: #8a6c00;
}
.tl-item {
display: flex; gap: 12px; position: relative;
padding: 4px 0;
}
.tl-item:not(:last-child)::before {
content: ''; position: absolute;
left: 40px; top: 28px; bottom: -4px;
width: 1.5px; background: #e8e4df;
}
.tl-time {
width: 50px; flex-shrink: 0; font-size: 12px;
color: #999; padding-top: 10px; text-align: right;
font-variant-numeric: tabular-nums;
}
.tl-dot-col {
width: 14px; flex-shrink: 0;
display: flex; flex-direction: column; align-items: center; padding-top: 10px;
}
.tl-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
}
.tl-card {
flex: 1; background: white; border: 1px solid #ede8e1;
border-radius: 10px; padding: 8px 14px; margin-bottom: 6px;
font-size: 13.5px;
}
.tl-badge {
display: inline-block; font-size: 10px;
padding: 2px 8px; border-radius: 4px; margin-left: 8px;
}
.template-edit-hint {
text-align: center; color: #aaa; font-size: 12px;
margin-top: 20px; padding: 12px;
border: 1.5px dashed #ddd; border-radius: 10px;
}
/* ===== 待办事项 Tab ===== */
.todo-layout {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.todo-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
}
.quadrant-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.quadrant {
background: white;
border-radius: 14px;
padding: 16px;
min-height: 150px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border-top: 4px solid;
display: flex;
flex-direction: column;
overflow: visible;
}
.quadrant-title {
font-size: 14px; font-weight: 600; margin-bottom: 4px;
}
.quadrant-desc {
font-size: 11px; color: #aaa; margin-bottom: 12px;
}
.q-urgent-important { border-top-color: #ef4444; background: #fef2f2; }
.q-urgent-important .quadrant-title { color: #dc2626; }
.q-important { border-top-color: #f59e0b; background: #fffbeb; }
.q-important .quadrant-title { color: #d97706; }
.q-urgent { border-top-color: #3b82f6; background: #eff6ff; }
.q-urgent .quadrant-title { color: #2563eb; }
.q-neither { border-top-color: #94a3b8; background: #f8fafc; }
.q-neither .quadrant-title { color: #64748b; }
.todo-list { flex: 1; overflow: visible; }
.todo-item {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
font-size: 14px;
overflow: visible;
cursor: grab;
}
.todo-item:last-child { border-bottom: none; }
.todo-check {
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid #d0d0d0; background: none;
cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 10px; transition: all 0.2s;
margin-top: 2px;
}
.todo-check.done { background: #667eea; border-color: #667eea; color: white; }
.todo-check:hover { border-color: #667eea; }
.todo-text { flex: 1; word-break: break-word; line-height: 1.5; min-width: 0; }
.todo-text.done { text-decoration: line-through; color: #bbb; }
.todo-toolbar {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px;
}
.todo-search {
flex: 1; padding: 8px 14px;
border: 1.5px solid #e0e0e0; border-radius: 10px;
font-size: 14px; outline: none;
}
.todo-search:focus { border-color: #667eea; }
.todo-toggle {
display: flex; align-items: center; gap: 6px;
cursor: pointer; flex-shrink: 0; font-size: 12px; color: #999;
user-select: none;
}
.todo-toggle input {
width: 16px; height: 16px; accent-color: #667eea; cursor: pointer;
}
.todo-deadline {
display: inline-block;
font-size: 10px; color: #999;
background: #f5f5f5;
padding: 2px 6px; border-radius: 4px;
cursor: pointer; white-space: nowrap;
flex-shrink: 0;
}
.todo-deadline.overdue {
color: #ef4444; background: #fef2f2; font-weight: 500;
}
.todo-deadline:hover { opacity: 0.7; }
.todo-deadline-add {
font-size: 10px; color: #ccc; background: none;
border: 1px dashed #ddd; border-radius: 4px;
padding: 2px 6px; cursor: pointer;
transition: all 0.15s; flex-shrink: 0;
white-space: nowrap;
}
.todo-deadline-add:hover { color: #667eea; border-color: #667eea; }
.todo-del {
background: none; border: none; color: #ddd;
cursor: pointer; font-size: 13px; padding: 2px 4px;
flex-shrink: 0;
}
.todo-del:hover { color: #ef4444; }
.todo-add-row {
display: flex; gap: 6px; margin-top: 10px;
}
.todo-add-row input, .todo-add-row textarea {
flex: 1; padding: 7px 10px;
border: 1.5px solid #e8e8e8; border-radius: 8px;
font-size: 12px; outline: none; font-family: inherit;
}
.todo-add-row input:focus, .todo-add-row textarea:focus { border-color: #667eea; }
.todo-add-row button {
padding: 6px 10px; background: #667eea; color: white;
border: none; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.todo-stats {
display: flex; gap: 8px; margin-bottom: 12px;
}
.stat-card {
flex: 1; background: white; border-radius: 12px;
padding: 14px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
text-align: center;
}
.stat-num { font-size: 24px; font-weight: 700; color: #667eea; }
.stat-label { font-size: 11px; color: #aaa; margin-top: 2px; }
/* ===== 周回顾 Tab ===== */
.review-layout {
max-width: 600px;
margin: 0 auto;
padding: 24px;
}
.review-section {
background: white; border-radius: 14px;
padding: 20px; margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.review-section h3 {
font-size: 15px; margin-bottom: 12px;
}
.review-textarea {
width: 100%; min-height: 70px; padding: 10px 12px;
border: 1.5px solid #e8e8e8; border-radius: 8px;
font-size: 13px; font-family: inherit;
outline: none; resize: vertical; line-height: 1.6;
}
.review-textarea:focus { border-color: #667eea; }
.review-history-item {
background: white; border-radius: 12px;
padding: 16px; margin-bottom: 12px;
border: 1px solid #eee;
}
.review-history-item .rh-label { font-size: 12px; color: #aaa; }
.review-history-item .rh-text { font-size: 13px; margin-top: 2px; margin-bottom: 8px; }
/* ===== Overlays ===== */
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.4); z-index: 200;
align-items: center; justify-content: center;
}
.overlay.open { display: flex; }
/* 自定义对话框 */
.dialog-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.4); z-index: 9000;
align-items: center; justify-content: center;
}
.dialog-overlay.open { display: flex; }
.dialog-box {
background: white; border-radius: 16px;
padding: 20px; max-width: 300px; width: 85%;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
animation: dialogIn 0.2s ease-out;
}
@keyframes dialogIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.dialog-msg {
font-size: 13px; color: #555; line-height: 1.5;
margin-bottom: 12px; text-align: center;
}
.dialog-input {
width: 100%; padding: 8px 12px;
border: 1.5px solid #e0e0e0; border-radius: 10px;
font-size: 14px; outline: none; margin-bottom: 12px;
font-family: inherit;
}
.dialog-input:focus { border-color: #667eea; }
.dialog-input[type="date"], .dialog-input[type="time"] {
padding: 8px 10px;
color: #444;
}
.dialog-btns {
display: flex; gap: 8px; justify-content: center;
}
.dialog-btns button {
padding: 8px 18px; border-radius: 10px; border: none;
font-size: 13px; cursor: pointer; font-weight: 500;
transition: all 0.15s;
}
.dialog-cancel { background: #f0f0f0; color: #666; }
.dialog-cancel:hover { background: #e0e0e0; }
.dialog-ok { background: #667eea; color: white; }
.dialog-ok:hover { background: #5a6fd6; }
.dialog-danger { background: #ef4444; color: white; }
.dialog-danger:hover { background: #dc2626; }
.panel {
background: white; border-radius: 16px;
padding: 24px; box-shadow: 0 16px 48px rgba(0,0,0,0.2);
}
.edit-panel { width: 300px; }
.edit-panel h3 { font-size: 16px; margin-bottom: 16px; color: #444; }
.edit-panel label { font-size: 13px; color: #888; display: block; margin-bottom: 6px; }
.edit-panel input[type="text"] {
width: 100%; padding: 8px 12px;
border: 1.5px solid #e0e0e0; border-radius: 8px;
font-size: 14px; outline: none; margin-bottom: 14px;
}
.edit-panel input[type="text"]:focus { border-color: #667eea; }
.edit-color-row, .edit-emoji-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.edit-actions { display: flex; gap: 10px; justify-content: flex-end; }
.emoji-option {
width: 32px; height: 32px; border-radius: 8px;
border: 2px solid transparent; background: #f5f5f5;
cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.emoji-option:hover { background: #eee; }
.emoji-option.selected { border-color: #667eea; background: #f0f0ff; }
.modal { max-width: 520px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal h2 { font-size: 18px; margin-bottom: 16px; }
.modal pre {
background: #f5f5f5; padding: 16px; border-radius: 10px;
font-size: 13px; line-height: 1.7;
white-space: pre-wrap; font-family: -apple-system, sans-serif;
}
.modal-actions { display: flex; gap: 10px; margin-top: 16px; justify-content: flex-end; }
.dragging { opacity: 0.5; }
/* ===== 睡眠记录 Tab ===== */
.sleep-layout {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.sleep-top {
display: flex; gap: 14px; margin-bottom: 20px;
}
.sleep-record-card {
flex: 1; min-width: 260px;
background: white; border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.sleep-record-card h3 { font-size: 15px; margin-bottom: 14px; }
.sleep-input-row {
display: flex; align-items: center; gap: 10px;
margin-bottom: 10px; font-size: 13px;
}
.sleep-input-row label {
width: 70px; flex-shrink: 0; color: #666;
}
.sleep-input-row input[type="time"],
.sleep-input-row input[type="date"] {
padding: 7px 10px; border: 1.5px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none;
font-family: inherit;
}
.sleep-input-row input:focus { border-color: #667eea; }
.sleep-text-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e8e8e8;
border-radius: 12px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.sleep-text-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.1);
}
.sleep-text-input::placeholder { color: #bbb; }
.sleep-summary-card {
flex: 1; min-width: 200px;
background: white; border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center;
}
.sleep-big-num { font-size: 36px; font-weight: 700; color: #667eea; }
.sleep-big-label { font-size: 12px; color: #aaa; margin-top: 4px; }
.sleep-sub-stats { display: flex; gap: 20px; margin-top: 12px; }
.sleep-sub-stat { text-align: center; }
.sleep-sub-num { font-size: 16px; font-weight: 600; color: #555; }
.sleep-sub-label { font-size: 11px; color: #bbb; }
.sleep-chart-card {
background: white; border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
margin-bottom: 16px;
}
.sleep-chart-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.sleep-chart-header h3 { font-size: 15px; }
.sleep-month-nav {
display: flex; align-items: center; gap: 10px;
}
.sleep-month-nav button {
width: 28px; height: 28px; border-radius: 50%;
border: none; background: #f0f0f0; cursor: pointer;
font-size: 14px; transition: all 0.2s;
}
.sleep-month-nav button:hover { background: #667eea; color: white; }
.sleep-month-nav span { font-size: 14px; font-weight: 600; color: #555; min-width: 100px; text-align: center; }
.sleep-chart-canvas {
width: 100%; height: 220px;
display: block;
}
.sleep-history-card {
background: white; border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.sleep-history-card h3 { font-size: 15px; margin-bottom: 12px; }
.sleep-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.sleep-table th {
text-align: left; padding: 8px 10px;
border-bottom: 2px solid #eee; color: #999;
font-weight: 500; font-size: 12px;
}
.sleep-table td {
padding: 8px 10px; border-bottom: 1px solid #f5f5f5;
}
.sleep-table tr:hover td { background: #fafafa; }
.sleep-duration-bar {
height: 6px; border-radius: 3px; background: #e8e8e8;
position: relative; width: 80px; display: inline-block;
vertical-align: middle; margin-left: 6px;
}
.sleep-duration-fill {
height: 100%; border-radius: 3px;
position: absolute; left: 0; top: 0;
}
.sleep-del-btn {
background: none; border: none; color: #ddd;
cursor: pointer; font-size: 14px;
}
.sleep-del-btn:hover { color: #ef4444; }
/* ===== 随手记 Tab ===== */
.notes-layout {
max-width: 700px;
margin: 0 auto;
padding: 24px;
}
.notes-capture {
background: white;
border-radius: 14px;
padding: 14px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.notes-capture-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.notes-capture-input {
flex: 1;
min-height: 44px;
padding: 10px 12px;
border: 1.5px solid #e8e8e8;
border-radius: 12px;
font-size: 16px;
font-family: inherit;
outline: none;
resize: none;
line-height: 1.6;
color: #333;
transition: border-color 0.2s;
}
.notes-capture-input:focus { border-color: #667eea; }
.notes-capture-input::placeholder { color: #bbb; }
.notes-save-btn {
width: 44px; height: 44px;
border-radius: 50%;
border: none;
background: #667eea;
color: white;
font-size: 20px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
box-shadow: 0 2px 8px rgba(102,126,234,0.3);
}
.notes-save-btn:hover { background: #5a6fd6; transform: scale(1.05); }
.notes-save-btn:active { transform: scale(0.95); }
.notes-tag-btns {
display: flex; gap: 6px; flex-wrap: wrap;
margin-top: 10px;
}
.notes-tag-btn {
padding: 4px 8px; border-radius: 10px;
border: 1.5px solid #e0e0e0; background: white;
font-size: 16px; cursor: pointer; transition: all 0.15s;
line-height: 1;
}
.notes-tag-btn:hover { border-color: #667eea; transform: scale(1.1); }
.notes-tag-btn.active { background: #667eea; color: white; border-color: #667eea; }
.notes-toolbar {
display: flex; gap: 8px; margin-bottom: 10px; align-items: center;
}
.notes-filter-toggle {
padding: 6px 12px; border-radius: 8px;
border: 1px solid #ddd; background: white;
font-size: 12px; color: #888; cursor: pointer;
white-space: nowrap; flex-shrink: 0;
}
.notes-filter-toggle:hover { border-color: #aaa; }
.notes-filter-row {
display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap;
}
.notes-filter {
padding: 7px 16px; border-radius: 20px;
border: 1.5px solid #ddd; background: white;
font-size: 13px; cursor: pointer; transition: all 0.2s;
}
.notes-filter:hover { border-color: #667eea; color: #667eea; }
.notes-filter.active { background: #667eea; color: white; border-color: #667eea; }
.notes-search-row { margin-bottom: 16px; }
.notes-search {
width: 100%; padding: 10px 14px;
border: 1.5px solid #e0e0e0; border-radius: 10px;
font-size: 14px; outline: none;
}
.notes-search:focus { border-color: #667eea; }
.note-card {
background: white; border-radius: 12px;
padding: 14px 16px; margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border-left: 4px solid #ddd;
position: relative;
transition: all 0.15s;
}
.note-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.note-card.tag-灵感 { border-left-color: #f59e0b; }
.note-card.tag-备忘 { border-left-color: #3b82f6; }
.note-card.tag-读书 { border-left-color: #22c55e; }
.note-card.tag-待办 { border-left-color: #ef4444; }
.note-card.tag-睡眠 { border-left-color: #7c3aed; }
.note-card.tag-健康 { border-left-color: #059669; }
.note-card.tag-音乐 { border-left-color: #f59e0b; }
.note-card .nc-top {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.note-card .nc-tag {
font-size: 11px; color: #999;
}
.note-card .nc-time {
font-size: 11px; color: #ccc;
}
.note-card .nc-text {
font-size: 14px; line-height: 1.6; color: #444;
white-space: pre-wrap; word-break: break-word;
}
.note-card .nc-actions-bottom {
display: flex; gap: 8px;
margin-top: 10px; padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.nc-done-btn {
padding: 4px 12px; border-radius: 6px;
border: 1px solid #e0e0e0; background: white;
color: #667eea; font-size: 12px;
cursor: pointer; transition: all 0.15s;
}
.nc-done-btn:hover { background: #667eea; color: white; border-color: #667eea; }
.nc-done-btn.is-done { color: #999; }
.nc-done-btn.is-done:hover { background: #f0f0f0; color: #666; border-color: #ddd; }
.nc-todo-btn {
padding: 4px 12px; border-radius: 6px;
border: 1px solid #e0e0e0; background: white;
color: #667eea; font-size: 12px;
cursor: pointer; transition: all 0.15s;
}
.nc-todo-btn:hover { background: #667eea; color: white; border-color: #667eea; }
.nc-del-btn {
padding: 4px 12px; border-radius: 6px;
border: 1px solid #f0e0e0; background: white;
color: #ccc; font-size: 12px;
cursor: pointer; transition: all 0.15s;
}
.nc-del-btn:hover { background: #fee; color: #ef4444; border-color: #fcc; }
/* 已处理的卡片变暗 */
.note-card.nc-done {
opacity: 0.45;
border-left-color: #ddd !important;
}
.note-card.nc-done:hover { opacity: 0.65; }
.nc-done-divider {
display: flex; align-items: center; gap: 12px;
margin: 20px 0 12px;
color: #ccc; font-size: 12px;
}
.nc-done-divider::before {
content: ''; flex: 1; height: 1px; background: #e8e8e8;
}
.nc-clear-done {
font-size: 11px; color: #ccc; background: none; border: 1px dashed #ddd;
border-radius: 4px; padding: 2px 8px; cursor: pointer; margin-left: 8px;
}
.nc-clear-done:hover { color: #ef4444; border-color: #fcc; }
/* ===== 健康管理 ===== */
.health-layout {
max-width: 800px; margin: 0 auto; padding: 24px;
}
.health-section {
background: white; border-radius: 14px; padding: 20px;
margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.health-section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.health-section-header h3 { font-size: 16px; }
.health-date { font-size: 13px; color: #888; }
.health-month-nav { display: flex; align-items: center; gap: 8px; }
.health-month-nav button {
width: 28px; height: 28px; border-radius: 50%;
border: none; background: #f0f0f0; cursor: pointer; font-size: 14px;
}
.health-month-nav button:hover { background: #667eea; color: white; }
.health-month-nav span { font-size: 14px; font-weight: 600; color: #555; min-width: 80px; text-align: center; }
.health-today-grid {
display: flex; gap: 10px; flex-wrap: wrap;
}
.health-today-item {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 10px;
border: 1.5px dashed #ccc; background: white;
font-size: 14px; cursor: pointer; color: #333;
transition: all 0.2s; user-select: none;
}
.health-today-item:hover { background: #f5f5ff; border-color: #aaa; }
.health-today-item.done {
background: #f0f0f0; color: #aaa;
border: 1.5px solid #ddd; border-style: solid;
text-decoration: line-through;
}
.health-empty {
text-align: center; color: #ccc; font-size: 13px; padding: 20px;
}
.health-pool {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;
}
.health-pool-item {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 10px;
background: #f5f5f5; font-size: 13px;
cursor: pointer; transition: all 0.15s;
position: relative;
}
.health-pool-item:hover { background: #e8e8ff; }
.health-pool-item.in-plan {
background: #667eea; color: white;
}
.health-pool-item .hp-del {
display: none; position: absolute; right: -6px; top: -6px;
width: 18px; height: 18px; border-radius: 50%;
background: #ef4444; color: white; border: none;
font-size: 10px; cursor: pointer;
align-items: center; justify-content: center;
}
.health-pool-item .hp-edit {
display: none; position: absolute; right: 22px; top: -6px;
width: 18px; height: 18px; border-radius: 50%;
background: #667eea; color: white; border: none;
font-size: 9px; cursor: pointer;
align-items: center; justify-content: center;
}
/* 电脑上 hover 显示 */
.health-pool-item:hover .hp-edit { display: flex; }
.health-pool-item:hover .hp-del { display: flex; }
/* 手机上长按显示编辑菜单 */
.pool-item-actions-mobile {
display: none;
position: fixed;
bottom: 0; left: 0; right: 0;
background: white;
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
z-index: 10001;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.pool-item-actions-mobile.open { display: block; }
.pool-item-actions-mobile button {
display: block; width: 100%; padding: 14px;
border: none; background: none; font-size: 16px;
text-align: center; cursor: pointer; border-radius: 10px;
}
.pool-item-actions-mobile button:hover { background: #f5f5f5; }
.pool-item-actions-mobile .pam-danger { color: #ef4444; }
.pool-item-actions-mobile .pam-cancel { color: #999; margin-top: 8px; border-top: 1px solid #eee; padding-top: 14px; }
.health-add-row {
display: flex; gap: 6px;
}
.health-add-row input {
flex: 1; padding: 8px 12px; border: 1.5px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none;
}
.health-add-row input:focus { border-color: #667eea; }
.health-add-row button {
padding: 8px 12px; background: #667eea; color: white;
border: none; border-radius: 8px; cursor: pointer; font-size: 16px;
}
/* 月历 */
.health-calendar {
overflow-x: auto;
}
.health-cal-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.health-cal-table th {
padding: 6px 4px; text-align: center; color: #aaa; font-weight: 500;
border-bottom: 1px solid #f0f0f0;
}
.health-cal-table td {
padding: 4px 2px; text-align: center; min-width: 24px;
}
.health-cal-table td:first-child,
.health-cal-table th:first-child {
position: sticky; left: 0;
background: white; z-index: 1;
}
.health-cal-dot {
width: 18px; height: 18px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px;
}
.health-cal-dot.done { background: #22c55e; color: white; }
.health-cal-dot.missed { background: #fee; color: #ef4444; }
.health-cal-dot.future { background: #f5f5f5; color: #ccc; }
.health-cal-dot.today { box-shadow: 0 0 0 2px #667eea; }
.health-diary-header {
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none;
}
.health-diary-arrow {
color: #ccc; font-size: 16px; transition: transform 0.2s;
}
.health-diary-arrow.open { transform: rotate(90deg); }
.health-diary-add { margin-top: 14px; }
.health-diary-textarea {
width: 100%; padding: 10px 12px;
border: 1.5px solid #e8e8e8; border-radius: 10px;
font-size: 14px; font-family: inherit;
outline: none; resize: none; line-height: 1.6;
}
.health-diary-textarea:focus { border-color: #667eea; }
.health-diary-item {
padding: 12px 0; border-bottom: 1px solid #f5f5f5;
display: flex; gap: 10px;
}
.health-diary-item:last-child { border-bottom: none; }
.hd-date { font-size: 12px; color: #aaa; min-width: 65px; flex-shrink: 0; padding-top: 2px; }
.hd-text { font-size: 14px; color: #444; line-height: 1.6; flex: 1; white-space: pre-wrap; word-break: break-word; }
.hd-del {
background: none; border: none; color: #ddd;
cursor: pointer; font-size: 14px; flex-shrink: 0;
}
.hd-del:hover { color: #ef4444; }
.checkin-view-btn {
padding: 4px 12px; border-radius: 6px;
border: 1.5px solid #ddd; background: white;
font-size: 12px; cursor: pointer; transition: all 0.15s;
color: #888;
}
.checkin-view-btn:hover { border-color: #667eea; color: #667eea; }
.checkin-view-btn.active { background: #667eea; color: white; border-color: #667eea; }
.year-month-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.year-month-block {}
.year-month-label {
font-size: 11px; color: #aaa; margin-bottom: 3px;
font-weight: 500;
}
.year-month-cells {
display: flex; flex-wrap: wrap; gap: 2px;
}
.year-cell {
width: 10px; height: 10px; border-radius: 2px;
background: #f0f0f0;
}
.year-cell.done { background: #22c55e; }
.year-cell.missed { background: #fecaca; }
.year-cell.today { box-shadow: 0 0 0 1.5px #667eea; }
@media (max-width: 700px) {
.year-month-grid { grid-template-columns: repeat(3, 1fr); }
}
.year-legend {
display: flex; gap: 12px; margin-top: 8px; font-size: 10px; color: #aaa;
align-items: center;
}
.year-legend-dot {
width: 10px; height: 10px; border-radius: 2px; display: inline-block;
vertical-align: middle; margin-right: 3px;
}
/* ===== 个人文档 ===== */
.docs-layout {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.docs-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
}
.doc-card {
background: white;
border-radius: 14px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
}
.doc-card-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px;
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.doc-card-header:hover { background: #fafafa; }
.doc-card-icon { font-size: 28px; }
.doc-card-info { flex: 1; }
.doc-card-name { font-size: 16px; font-weight: 600; color: #333; }
.doc-card-meta { font-size: 12px; color: #aaa; margin-top: 2px; }
.doc-card-count {
background: #f0f0f0; color: #888;
font-size: 12px; font-weight: 600;
padding: 3px 10px; border-radius: 10px;
}
.doc-card-arrow {
color: #ccc; font-size: 14px;
transition: transform 0.2s;
}
.doc-card.open .doc-card-arrow { transform: rotate(90deg); }
.doc-card-actions {
display: none; gap: 4px; margin-left: auto; margin-right: 8px;
}
.doc-card-header:hover .doc-card-actions { display: flex; }
.doc-card-actions button {
width: 24px; height: 24px; border-radius: 6px;
border: none; background: #f0f0f0; color: #999;
cursor: pointer; font-size: 11px;
display: flex; align-items: center; justify-content: center;
}
.doc-card-actions button:hover { background: #e0e0e0; color: #666; }
.doc-card-body {
display: none;
padding: 0 20px 16px;
border-top: 1px solid #f0f0f0;
}
.doc-card.open .doc-card-body { display: block; }
.doc-entry {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f8f8f8;
font-size: 13px;
}
.doc-entry:last-child { border-bottom: none; }
.doc-entry-date {
flex-shrink: 0; color: #aaa; font-size: 12px;
min-width: 70px; padding-top: 1px;
}
.doc-entry-text { flex: 1; color: #444; line-height: 1.5; }
.doc-entry-extracted {
font-weight: 600; color: #667eea;
}
.doc-entry-source {
font-size: 11px; color: #ccc; margin-top: 2px;
}
.doc-entry-del {
background: none; border: none; color: #ddd;
cursor: pointer; font-size: 13px; flex-shrink: 0;
}
.doc-entry-del:hover { color: #ef4444; }
.doc-empty {
text-align: center; color: #ccc; font-size: 13px;
padding: 20px 0;
}
.doc-routed-badge {
display: inline-block;
background: #f0f0ff;
color: #667eea;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
margin-top: 6px;
}
/* ===== 提醒页 ===== */
.reminders-layout {
max-width: 700px;
margin: 0 auto;
padding: 24px;
}
.reminders-panel {}
.rp-card {
background: white; border-radius: 12px;
padding: 14px 18px; margin-bottom: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
display: flex; align-items: center; gap: 12px;
font-size: 13px;
}
.rp-card.rp-done { opacity: 0.4; }
.rp-icon { font-size: 20px; flex-shrink: 0; }
.rp-info { flex: 1; min-width: 0; }
.rp-title { font-weight: 600; color: #333; }
.rp-time { font-size: 11px; color: #aaa; margin-top: 1px; }
.rp-desc { font-size: 12px; color: #888; margin-top: 2px; }
.rp-status {
font-size: 11px; padding: 3px 10px;
border-radius: 6px; flex-shrink: 0; font-weight: 500;
}
.rp-status.pending { background: #fff3e0; color: #e65100; }
.rp-status.done { background: #e8f5e9; color: #2e7d32; }
.rp-status.upcoming { background: #f5f5f5; color: #999; }
.rp-action-btn {
width: 24px; height: 24px; border-radius: 6px;
border: none; background: #f0f0f0; color: #999;
cursor: pointer; font-size: 12px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.rp-action-btn:hover { background: #e0e0e0; color: #666; }
.rp-action-btn.rp-del:hover { background: #fee; color: #ef4444; }
.rp-edit-form {
background: #fafafa; border-radius: 10px;
padding: 12px 16px; margin-bottom: 8px;
border: 1px solid #eee;
}
.rp-edit-row {
display: flex; align-items: center; gap: 10px;
margin-bottom: 8px;
}
.rp-edit-row:last-child { margin-bottom: 0; }
.rp-edit-row label {
font-size: 13px; color: #888; min-width: 40px; flex-shrink: 0;
}
.rp-edit-row input {
flex: 1; padding: 7px 10px;
border: 1.5px solid #e0e0e0; border-radius: 8px;
font-size: 14px; outline: none;
}
.rp-edit-row input:focus { border-color: #667eea; }
/* ===== 收集箱 ===== */
.inbox-card {
background: white;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border-left: 4px solid #667eea;
}
.inbox-input-area {
display: flex;
gap: 8px;
}
.inbox-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #e8e8e8;
border-radius: 10px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.inbox-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.1);
}
.inbox-input::placeholder { color: #bbb; }
.inbox-add-btn {
width: 46px; height: 46px;
border-radius: 12px;
border: none;
background: #667eea;
color: white;
font-size: 22px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.inbox-add-btn:hover { background: #5a6fd6; }
.inbox-list {
margin-top: 12px;
}
.inbox-list:empty { margin-top: 0; }
.inbox-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #f8f8ff;
border-radius: 10px;
margin-bottom: 6px;
font-size: 13px;
animation: inboxFadeIn 0.25s ease-out;
}
@keyframes inboxFadeIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.inbox-item .inbox-text {
flex: 1; color: #444;
}
.inbox-item .inbox-time {
font-size: 11px; color: #bbb; flex-shrink: 0;
}
.inbox-move-btns {
display: flex; gap: 4px; flex-shrink: 0;
}
.inbox-move-btn {
width: 24px; height: 24px;
border-radius: 6px; border: none;
font-size: 11px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
color: white;
}
.inbox-move-btn:hover { transform: scale(1.15); }
.inbox-move-btn.to-q1 { background: #ef4444; }
.inbox-move-btn.to-q2 { background: #f59e0b; }
.inbox-move-btn.to-q3 { background: #3b82f6; }
.inbox-move-btn.to-q4 { background: #94a3b8; }
.inbox-del-btn {
background: none; border: none; color: #ddd;
cursor: pointer; font-size: 14px; padding: 2px 4px;
}
.inbox-del-btn:hover { color: #ef4444; }
.inbox-badge {
display: inline-flex; align-items: center; justify-content: center;
background: #667eea; color: white;
font-size: 11px; font-weight: 600;
min-width: 20px; height: 20px;
border-radius: 10px; padding: 0 6px;
margin-left: 6px;
}
/* ===== 睡眠提醒 ===== */
/* ===== 提醒弹窗Google Calendar 风格) ===== */
.sleep-reminder {
display: none;
position: fixed;
inset: 0;
z-index: 10002;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
}
.sleep-reminder.show { display: flex; }
.reminder-card {
background: white;
border-radius: 20px;
width: 90%;
max-width: 360px;
overflow: hidden;
animation: reminderPop 0.3s ease-out;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
@keyframes reminderPop {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
.reminder-color-bar {
height: 6px;
background: linear-gradient(90deg, #667eea, #764ba2);
}
.reminder-body {
padding: 28px 24px 20px;
text-align: center;
}
.reminder-icon {
font-size: 48px;
margin-bottom: 12px;
animation: reminderBounce 1s ease-in-out infinite;
}
@keyframes reminderBounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.reminder-title {
font-size: 20px;
font-weight: 700;
color: #333;
margin-bottom: 8px;
}
.reminder-msg {
font-size: 14px;
color: #888;
line-height: 1.8;
margin-bottom: 24px;
white-space: pre-line;
text-align: left;
}
.reminder-dismiss {
display: block;
width: 100%;
padding: 14px;
background: #667eea;
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.reminder-dismiss:hover { background: #5a6fd6; }
.reminder-dismiss:active { transform: scale(0.97); }
@media (max-width: 700px) {
.schedule-layout { flex-direction: column; padding: 12px; }
.module-pool { width: 100%; position: static; }
.quadrant-grid { grid-template-columns: 1fr; }
.sleep-top { flex-direction: column; }
/* 手机上随手记 */
.notes-layout { padding: 0; }
.notes-capture {
padding: 0;
margin: 0;
border-radius: 0;
box-shadow: none;
min-height: calc(100vh - 90px);
display: flex;
flex-direction: column;
}
.notes-capture-row {
flex: 1;
align-items: stretch;
padding: 12px;
padding-bottom: 0;
}
.notes-capture-input {
font-size: 18px;
min-height: 100%;
padding: 14px;
border: none;
border-radius: 0;
}
.notes-capture-input:focus { border: none; box-shadow: none; }
/* 保存按钮固定右下角 */
.notes-save-btn {
position: fixed;
bottom: 24px;
right: 20px;
width: 60px;
height: 60px;
font-size: 26px;
z-index: 50;
box-shadow: 0 4px 16px rgba(102,126,234,0.4);
}
.notes-tag-btns { gap: 4px; padding: 8px 12px; flex-shrink: 0; }
.notes-tag-btn { padding: 5px 10px; font-size: 11px; }
.notes-toolbar { padding: 0 12px; }
.notes-filter-row { gap: 4px; margin-bottom: 8px; padding: 0 12px; }
.notes-filter { padding: 5px 10px; font-size: 12px; }
.note-card { padding: 10px 12px; margin: 0 12px 8px; }
#notesEmpty { padding: 20px 12px !important; }
#notesList { padding-bottom: 80px; }
/* 手机上其他页面紧凑 */
.header-main { padding: 10px 16px; }
.tabs { padding: 0 8px; }
.tab-btn { padding: 8px 10px; font-size: 12px; }
.todo-layout, .health-layout, .template-layout,
.review-layout, .docs-layout, .sleep-layout,
.reminders-layout { padding: 12px; }
.health-section, .review-section { padding: 14px; }
.quadrant { padding: 12px; }
.stat-card { padding: 10px; }
.stat-num { font-size: 20px; }
header h1 { font-size: 17px; }
.header-subtitle { font-size: 10px; margin-left: 6px; }
}
</style>
</head>
<body>
<!-- 登录页 -->
<div class="login-overlay" id="loginOverlay">
<div class="login-banner">
<div class="circle c1"></div>
<div class="circle c2"></div>
<div class="circle c3"></div>
</div>
<div class="login-card">
<div class="login-title">Hera's Planner</div>
<div class="login-subtitle">记录生活,管理时间,照顾自己</div>
<div class="login-input-wrap">
<div style="position:relative;flex:1;">
<input type="password" class="login-input" id="loginPass" placeholder="输入密码" style="width:100%;padding-right:36px;"
onkeydown="if(event.key==='Enter'&&!event.isComposing)doLogin()">
<button onclick="const p=document.getElementById('loginPass');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'👁‍🗨'" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:16px;color:rgba(255,255,255,0.4);">👁</button>
</div>
<button class="login-btn" onclick="doLogin()">进入</button>
</div>
<div class="login-error" id="loginError"></div>
<a href="/sleep" style="display:block;margin-top:24px;color:rgba(255,255,255,0.4);font-size:12px;text-decoration:none;">🌙 睡眠打卡入口</a>
</div>
</div>
<!-- 全局提醒弹窗 -->
<div class="sleep-reminder" id="sleepReminder">
<div class="reminder-card">
<div class="reminder-color-bar"></div>
<div class="reminder-body">
<div class="reminder-icon" id="reminderIcon">🌙</div>
<div class="reminder-title" id="reminderTitle">该准备休息啦</div>
<div class="reminder-msg" id="reminderMsg">今天也辛苦了,好好睡一觉,明天会更好</div>
<button class="reminder-dismiss" onclick="dismissReminder('sleep')">
记下了,去睡觉 ✨
</button>
</div>
</div>
</div>
<header>
<div class="header-main">
<div class="header-top">
<h1 onclick="toggleSidebar()" style="cursor:pointer;">☰ Hera's Planner <span class="header-subtitle">v118</span></h1>
<div style="display:flex;align-items:center;gap:6px;">
<span id="syncStatus" style="font-size:10px;color:#aaa;"></span>
<div class="header-actions" id="headerActions"></div>
</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="notes">随手记</button>
<button class="tab-btn" data-tab="tasks">待办</button>
<button class="tab-btn" data-tab="reminders">提醒</button>
<button class="tab-btn" data-tab="body">身体</button>
<button class="tab-btn" data-tab="music">音乐</button>
<button class="tab-btn" data-tab="docs">文档</button>
<button class="tab-btn" data-tab="planning">日程</button>
</div>
</header>
<!-- 侧边栏 -->
<div id="sidebarMask" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:900;" onclick="toggleSidebar()"></div>
<div id="sidebar" style="display:none;position:fixed;top:0;left:0;bottom:0;width:200px;background:white;z-index:901;box-shadow:4px 0 20px rgba(0,0,0,0.1);padding:60px 0 20px;overflow-y:auto;transition:transform 0.2s;">
</div>
<!-- ==================== 提醒 ==================== -->
<div class="tab-content" id="tab-reminders">
<div class="reminders-layout">
<div class="reminders-panel" id="remindersPanel"></div>
</div>
</div>
<!-- ==================== 健身记录 ==================== -->
<div class="tab-content" id="tab-gym">
<div class="health-layout">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<h3 style="font-size:16px;color:#444;">💪 健身记录</h3>
<button class="btn btn-accent" onclick="openAddGym()">+ 记录</button>
</div>
<div id="gymList"></div>
</div>
</div>
<!-- ==================== 经期记录 ==================== -->
<div class="tab-content" id="tab-period">
<div class="health-layout">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<h3 style="font-size:16px;color:#444;">🌸 经期记录</h3>
<button class="btn btn-accent" onclick="openAddPeriod()">+ 记录</button>
</div>
<div id="periodSummary" style="margin-bottom:14px;"></div>
<div id="periodList"></div>
</div>
</div>
<!-- ==================== 日程规划 ==================== -->
<div class="tab-content" id="tab-schedule">
<div class="schedule-layout">
<div class="module-pool">
<div class="pool-card">
<h2>活动模块</h2>
<div id="moduleList"></div>
<div class="add-module-row">
<input id="newModuleName" type="text" placeholder="添加新活动…"
onkeydown="if(event.key==='Enter'&&!event.isComposing)addModule()">
<button onclick="addModule()">+</button>
</div>
<div class="color-picker-row" id="colorPicker"></div>
</div>
</div>
<div class="timeline">
<div class="date-nav">
<button onclick="changeDate(-1)"></button>
<span class="date-label" id="dateLabel"></span>
<button onclick="changeDate(1)"></button>
<button class="btn btn-light" onclick="clearAll()" style="margin-left:auto;padding:5px 12px;font-size:12px;">清空</button>
<button class="btn btn-light" onclick="exportSchedule()" style="padding:5px 12px;font-size:12px;">导出</button>
</div>
<div id="timeSlots"></div>
</div>
</div>
</div>
<!-- ==================== 每周模板 ==================== -->
<div class="tab-content" id="tab-template">
<div class="template-layout">
<div class="day-tabs" id="dayTabs"></div>
<div id="dayNote"></div>
<div id="dayTimeline"></div>
<div class="template-edit-hint">
模板内容可以在下方 JS 的 WEEK_SCHEDULE 中编辑
</div>
</div>
</div>
<!-- ==================== 待办事项 ==================== -->
<div class="tab-content" id="tab-todo">
<div class="todo-layout">
<!-- 搜索和开关 -->
<div class="todo-toolbar">
<input type="text" class="todo-search" id="todoSearch" placeholder="搜索待办…" oninput="renderTodo()">
<label class="todo-toggle">
<input type="checkbox" id="todoShowDone" onchange="renderTodo()">
<span class="todo-toggle-label">显示已完成</span>
</label>
</div>
<!-- 收集箱 -->
<div class="inbox-card" id="inboxCard">
<div class="inbox-input-area">
<textarea class="inbox-input" id="inboxInput" rows="1"
placeholder="脑子里有什么事?先丢进来…多条用空行分隔"
style="resize:none;min-height:40px;"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();addInboxItem()}"></textarea>
<button class="inbox-add-btn" onclick="addInboxItem()">+</button>
</div>
<div class="inbox-list" id="inboxList"></div>
<div id="inboxClearBtn" style="display:none;text-align:right;margin-top:6px;">
<button style="font-size:11px;color:#ccc;background:none;border:1px dashed #ddd;border-radius:6px;padding:3px 10px;cursor:pointer;" onclick="clearInbox()">清空收集箱</button>
</div>
</div>
<div class="todo-stats" id="todoStats"></div>
<div class="quadrant-grid" id="quadrantGrid"></div>
</div>
</div>
<!-- ==================== 周回顾 ==================== -->
<div class="tab-content" id="tab-review">
<div class="review-layout">
<div class="review-section">
<h3>本周回顾</h3>
<div id="reviewForm"></div>
<div style="text-align:right;margin-top:14px;">
<button class="btn btn-accent" onclick="saveReview()">保存回顾</button>
</div>
</div>
<h3 style="font-size:14px;color:#888;margin-bottom:12px;padding-left:4px;cursor:pointer;user-select:none;" onclick="const el=document.getElementById('reviewHistory');el.style.display=el.style.display==='none'?'block':'none';this.textContent=el.style.display==='none'?'历史回顾 ':'历史回顾 ⌄'">历史回顾 </h3>
<div id="reviewHistory" style="display:none;"></div>
</div>
</div>
<!-- ==================== Bug ==================== -->
<div class="tab-content" id="tab-bugs">
<div class="health-layout">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<h3 style="font-size:16px;color:#444;">🐛 Bug 追踪</h3>
<button class="btn btn-accent" onclick="openAddBug()">+ 新增</button>
</div>
<div id="bugList"></div>
</div>
</div>
<!-- ==================== 睡眠记录 ==================== -->
<!-- ==================== 随手记 ==================== -->
<div class="tab-content active" id="tab-notes">
<div class="notes-layout">
<!-- 快速输入区 -->
<div class="notes-capture">
<div class="notes-capture-row">
<textarea class="notes-capture-input" id="notesCaptureInput"
placeholder="想到什么,写下来…"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,160)+'px';autoHighlightTag(this.value)"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();saveNoteFromMain()}"></textarea>
<button class="notes-save-btn" onclick="saveNoteFromMain()"></button>
</div>
<div class="notes-tag-btns">
<button class="notes-tag-btn active" data-tag="灵感" onclick="pickNoteTag(this)" title="灵感">💡</button>
<button class="notes-tag-btn" data-tag="待办" onclick="pickNoteTag(this)" title="待办"></button>
<button class="notes-tag-btn" data-tag="提醒" onclick="pickNoteTag(this)" title="提醒"></button>
<button class="notes-tag-btn" data-tag="读书" onclick="pickNoteTag(this)" title="读书">📖</button>
<button class="notes-tag-btn" data-tag="睡眠" onclick="pickNoteTag(this)" title="睡眠">🌙</button>
<button class="notes-tag-btn" data-tag="健康" onclick="pickNoteTag(this)" title="健康">💊</button>
<button class="notes-tag-btn" data-tag="健身" onclick="pickNoteTag(this)" title="健身">💪</button>
<button class="notes-tag-btn" data-tag="音乐" onclick="pickNoteTag(this)" title="音乐">🎵</button>
</div>
</div>
<!-- 筛选 + 列表 -->
<div class="notes-toolbar">
<input type="text" class="notes-search" id="notesSearch" placeholder="搜索…" oninput="renderNotes()">
<button class="notes-filter-toggle" onclick="toggleNotesFilter()">
<span id="notesFilterLabel">全部</span>
</button>
</div>
<div class="notes-filter-row" id="notesFilterRow" style="display:none;">
<button class="notes-filter active" data-filter="all" onclick="filterNotes('all',this)">全部</button>
<button class="notes-filter" data-filter="灵感" onclick="filterNotes('灵感',this)">💡 灵感</button>
<button class="notes-filter" data-filter="待办" onclick="filterNotes('待办',this)">✅ 待办</button>
<button class="notes-filter" data-filter="提醒" onclick="filterNotes('提醒',this)">⏰ 提醒</button>
<button class="notes-filter" data-filter="读书" onclick="filterNotes('读书',this)">📖 读书</button>
<button class="notes-filter" data-filter="睡眠" onclick="filterNotes('睡眠',this)">🌙 睡眠</button>
<button class="notes-filter" data-filter="健康" onclick="filterNotes('健康',this)">💊 健康</button>
<button class="notes-filter" data-filter="健身" onclick="filterNotes('健身',this)">💪 健身</button>
<button class="notes-filter" data-filter="音乐" onclick="filterNotes('音乐',this)">🎵 音乐</button>
</div>
<div id="notesList"></div>
<div id="notesEmpty" style="text-align:center;color:#ccc;padding:30px;font-size:13px;display:none;">
还没有记录,在上方输入框快速记录吧
</div>
</div>
</div>
<!-- ==================== 健康管理 ==================== -->
<div class="tab-content" id="tab-health">
<div class="health-layout">
<!-- 今日打卡 -->
<div class="health-section">
<div class="health-section-header">
<h3>今日打卡</h3>
<span class="health-date" id="healthDate"></span>
</div>
<div id="healthTodayGrid" class="health-today-grid"></div>
<div id="healthTodayEmpty" class="health-empty" style="display:none;">
还没有设定本月计划,从下方选择项目添加
</div>
</div>
<!-- 本月计划 -->
<div class="health-section">
<div class="health-section-header">
<h3 id="healthMonthTitle">本月计划</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="health-month-nav">
<button onclick="changeHealthMonth(-1)"></button>
<span id="healthMonthLabel"></span>
<button onclick="changeHealthMonth(1)"></button>
</div>
<button class="checkin-view-btn" id="healthViewBtn" onclick="toggleHealthView()">年度</button>
</div>
</div>
<div id="healthCalendar" class="health-calendar"></div>
</div>
<!-- 物品池 -->
<div class="health-section">
<h3 style="margin-bottom:12px;">物品池 <span style="font-size:12px;color:#aaa;font-weight:400;">点击添加到本月计划</span></h3>
<div id="healthPool" class="health-pool"></div>
<div class="health-add-row">
<input type="text" id="healthNewItem" placeholder="添加新项目维生素D"
onkeydown="if(event.key==='Enter'&&!event.isComposing)addHealthItem()">
<button onclick="addHealthItem()">+</button>
</div>
</div>
<!-- 健康日记 -->
<div class="health-section">
<div class="health-diary-header" onclick="toggleHealthDiary()">
<h3>健康日记</h3>
<span class="health-diary-arrow" id="diaryArrow"></span>
</div>
<div id="healthDiaryBody" style="display:none;">
<div class="health-diary-add">
<textarea id="healthDiaryInput" class="health-diary-textarea" rows="2"
placeholder="记录今天的身体状况、饮食、运动感受…"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,150)+'px'"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();addHealthDiary()}"></textarea>
<div style="text-align:right;margin-top:8px;">
<button class="btn btn-accent" onclick="addHealthDiary()">记录</button>
</div>
</div>
<div id="healthDiaryList"></div>
</div>
</div>
</div>
</div>
<!-- ==================== 音乐打卡 ==================== -->
<div class="tab-content" id="tab-music">
<div class="health-layout">
<div class="health-section">
<div class="health-section-header">
<h3>今日练习</h3>
<span class="health-date" id="musicDate"></span>
</div>
<div id="musicTodayGrid" class="health-today-grid"></div>
<div id="musicTodayEmpty" class="health-empty" style="display:none;">
还没有设定本月计划,从下方选择项目添加
</div>
</div>
<div class="health-section">
<div class="health-section-header">
<h3>练习记录</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="health-month-nav">
<button onclick="changeMusicMonth(-1)"></button>
<span id="musicMonthLabel"></span>
<button onclick="changeMusicMonth(1)"></button>
</div>
<button class="checkin-view-btn" id="musicViewBtn" onclick="toggleMusicView()">年度</button>
</div>
</div>
<div id="musicCalendar" class="health-calendar"></div>
</div>
<div class="health-section">
<h3 style="margin-bottom:12px;">练习项目 <span style="font-size:12px;color:#aaa;font-weight:400;">点击添加到本月计划</span></h3>
<div id="musicPool" class="health-pool"></div>
<div class="health-add-row">
<input type="text" id="musicNewItem" placeholder="添加新项目,如:尤克里里"
onkeydown="if(event.key==='Enter'&&!event.isComposing)addMusicItem()">
<button onclick="addMusicItem()">+</button>
</div>
</div>
</div>
</div>
<!-- ==================== 目标 ==================== -->
<div class="tab-content" id="tab-goals">
<div class="health-layout">
<div class="health-section">
<div class="health-section-header">
<h3>今日目标打卡</h3>
<span class="health-date" id="goalDate"></span>
</div>
<div id="goalTodayChecks"></div>
</div>
<div class="health-section">
<div class="health-section-header">
<h3>我的目标</h3>
<button class="btn btn-accent" onclick="openAddGoal()">+ 新目标</button>
</div>
<div id="goalList"></div>
</div>
</div>
</div>
<!-- 添加目标弹窗 -->
<div class="overlay" id="goalEditOverlay" onclick="if(event.target===this)closeGoalEdit()">
<div class="panel edit-panel" style="width:360px;">
<h3 id="goalEditTitle">新目标</h3>
<label>目标名称</label>
<input type="text" id="goalName" placeholder="如3月减肥5斤">
<label>截止月份</label>
<input type="month" id="goalMonth">
<div class="edit-actions" style="margin-top:14px;">
<button class="btn btn-close" onclick="closeGoalEdit()">取消</button>
<button class="btn btn-accent" onclick="saveGoalEdit()">保存</button>
</div>
</div>
</div>
<!-- ==================== 清单 ==================== -->
<div class="tab-content" id="tab-checklists">
<div class="health-layout">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<h3 style="font-size:16px;color:#444;">我的清单</h3>
<button class="btn btn-accent" onclick="addChecklist()">+ 新清单</button>
</div>
<input type="text" class="todo-search" id="checklistSearch" placeholder="搜索清单…" oninput="renderChecklists()" style="margin-bottom:12px;width:100%;">
<div id="checklistList"></div>
<div id="checklistArchive"></div>
</div>
</div>
<!-- ==================== 个人文档 ==================== -->
<div class="tab-content" id="tab-docs">
<div class="docs-layout">
<div class="docs-header">
<div>
<h2 style="font-size:18px;font-weight:600;color:#444;">个人文档</h2>
<p style="font-size:12px;color:#aaa;margin-top:2px;">随手记会自动识别内容,归档到对应文档</p>
</div>
<button class="btn btn-accent" onclick="openAddDoc()">+ 新建文档</button>
</div>
<div id="docCards"></div>
</div>
</div>
<!-- 新建/编辑文档弹窗 -->
<div class="overlay" id="docEditOverlay" onclick="if(event.target===this)closeDocEdit()">
<div class="panel edit-panel" style="width:360px;">
<h3 id="docEditTitle">新建文档</h3>
<label>文档名称</label>
<input type="text" id="docName" placeholder="如:读书记录">
<label>图标</label>
<div class="edit-emoji-row" id="docEmojiRow"></div>
<label>关键词</label>
<input type="text" id="docKeywords" placeholder="逗号分隔,如:读完,看完,读了" style="margin-bottom:4px;">
<div style="font-size:11px;color:#bbb;margin-bottom:14px;">随手记中包含这些词的内容会自动归档到这个文档</div>
<label>自动提取规则(可选)</label>
<select id="docExtractRule" style="width:100%;padding:8px 12px;border:1.5px solid #e0e0e0;border-radius:8px;font-size:13px;margin-bottom:14px;outline:none;">
<option value="none">无 - 保存原文</option>
<option value="sleep">睡眠时间 - 自动提取几点睡的</option>
<option value="book">书名 - 自动提取《》中的书名</option>
</select>
<div class="edit-actions">
<button class="btn btn-close" onclick="closeDocEdit()">取消</button>
<button class="btn btn-accent" onclick="saveDocEdit()">保存</button>
</div>
</div>
</div>
<div class="tab-content" id="tab-sleep">
<div class="sleep-layout">
<button onclick="goSleepNow()" style="display:block;width:100%;padding:16px;border:none;border-radius:14px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;font-size:18px;font-weight:600;cursor:pointer;margin-bottom:14px;box-shadow:0 4px 16px rgba(102,126,234,0.3);" id="plannerSleepBtn">🌙 我去睡觉啦</button>
<div class="sleep-top">
<div class="sleep-summary-card" id="sleepSummary" style="display:flex;flex-direction:column;justify-content:center;">
<div class="sleep-big-num">--</div>
<div class="sleep-big-label">本月平均入睡</div>
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:12px;">
<div class="sleep-record-card" style="margin:0;">
<h3>记录睡眠</h3>
<div style="display:flex;gap:10px;">
<input type="text" id="sleepTextInput" class="sleep-text-input"
placeholder="昨晚10:30 / 25号 9点半"
onkeydown="if(event.key==='Enter'&&!event.isComposing)parseSleepInput()">
<button class="btn btn-accent" onclick="parseSleepInput()" style="flex-shrink:0;">记录</button>
</div>
<div id="sleepParseHint" style="font-size:12px;color:#aaa;margin-top:6px;min-height:16px;"></div>
</div>
<div class="sleep-record-card" style="margin:0;padding:14px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:13px;color:#888;">目标入睡</span>
<div style="display:flex;align-items:center;gap:8px;">
<span id="plannerSleepTarget" style="font-size:16px;color:#667eea;font-weight:600;">22:00</span>
<button onclick="setPlannerSleepTarget()" style="font-size:12px;padding:3px 10px;border:1px solid #e0e0e0;border-radius:6px;background:white;color:#888;cursor:pointer;">修改</button>
</div>
</div>
</div>
</div>
</div>
<div class="sleep-chart-card">
<div class="sleep-chart-header">
<h3>睡眠趋势</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="sleep-month-nav">
<button onclick="changeSleepMonth(-1)"></button>
<span id="sleepMonthLabel"></span>
<button onclick="changeSleepMonth(1)"></button>
</div>
<button class="checkin-view-btn" id="sleepViewBtn" onclick="toggleSleepYearView()">年度</button>
<select id="sleepBuddySelect" onchange="renderSleep()" style="padding:4px 8px;border-radius:6px;border:1px solid #ddd;font-size:11px;color:#888;background:white;">
<option value="">只看我</option>
</select>
</div>
</div>
<div id="sleepChartContainer">
<canvas class="sleep-chart-canvas" id="sleepChart"></canvas>
</div>
<div id="sleepYearContainer" style="display:none;"></div>
</div>
<div class="sleep-history-card">
<h3>记录明细</h3>
<table class="sleep-table">
<thead><tr><th>日期</th><th>入睡时间</th><th></th></tr></thead>
<tbody id="sleepTableBody"></tbody>
</table>
<div id="sleepEmptyHint" style="text-align:center;color:#ccc;padding:20px;font-size:13px;display:none;">
还没有睡眠记录,快去记录吧
</div>
</div>
</div>
</div>
<!-- ===== 编辑模块弹窗 ===== -->
<div class="overlay" id="editOverlay" onclick="if(event.target===this)closeEdit()">
<div class="panel edit-panel">
<h3>编辑模块</h3>
<label>名称</label>
<input type="text" id="editName" onkeydown="if(event.key==='Enter'&&!event.isComposing)saveEdit()">
<label>图标</label>
<div class="edit-emoji-row" id="editEmojiRow"></div>
<label>颜色</label>
<div class="edit-color-row" id="editColorRow"></div>
<div class="edit-actions">
<button class="btn btn-close" onclick="closeEdit()">取消</button>
<button class="btn btn-accent" onclick="saveEdit()">保存</button>
</div>
</div>
</div>
<!-- ===== 导出弹窗 ===== -->
<div class="overlay" id="modalOverlay" onclick="if(event.target===this)this.classList.remove('open')">
<div class="panel modal" onclick="event.stopPropagation()">
<h2>今日日程</h2>
<pre id="exportText"></pre>
<div class="modal-actions">
<button class="btn btn-close" onclick="document.getElementById('modalOverlay').classList.remove('open')">关闭</button>
<button class="btn btn-accent" onclick="copyExport()">复制到剪贴板</button>
</div>
</div>
</div>
<div class="dialog-overlay" id="dialogOverlay">
<div class="dialog-box">
<div class="dialog-msg" id="dialogMsg"></div>
<input class="dialog-input" id="dialogInput" style="display:none;" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-bwignore="true">
<div class="dialog-btns" id="dialogBtns"></div>
</div>
</div>
<script>
// ============================================================
// 自定义对话框(居中显示)
// ============================================================
let _dialogResolve = null;
function showDialog(msg, type, defaultVal) {
return new Promise(resolve => {
_dialogResolve = resolve;
const overlay = document.getElementById('dialogOverlay');
const msgEl = document.getElementById('dialogMsg');
const inputEl = document.getElementById('dialogInput');
const btnsEl = document.getElementById('dialogBtns');
msgEl.textContent = msg;
if (type === 'prompt' || type === 'date' || type === 'time') {
inputEl.style.display = 'block';
inputEl.value = defaultVal || '';
if (type === 'date') inputEl.type = 'date';
else if (type === 'time') inputEl.type = 'time';
else inputEl.type = msg.includes('密码') ? 'password' : 'text';
const clearBtn = type === 'date' ? '<button class="dialog-cancel" onclick="closeDialog(\'\')">清除</button>' : '';
btnsEl.innerHTML = `
<button class="dialog-cancel" onclick="closeDialog(null)">取消</button>
${clearBtn}
<button class="dialog-ok" onclick="closeDialog(document.getElementById('dialogInput').value)">确定</button>`;
overlay.classList.add('open');
setTimeout(() => inputEl.focus(), 50);
inputEl.onkeydown = e => { if (e.key === 'Enter' && !e.isComposing) closeDialog(inputEl.value); };
} else {
// confirm
inputEl.style.display = 'none';
btnsEl.innerHTML = `
<button class="dialog-cancel" onclick="closeDialog(false)">取消</button>
<button class="dialog-danger" onclick="closeDialog(true)">确定</button>`;
overlay.classList.add('open');
}
});
}
function closeDialog(value) {
document.getElementById('dialogOverlay').classList.remove('open');
if (_dialogResolve) { _dialogResolve(value); _dialogResolve = null; }
}
// 替换原生 confirm 和 prompt
const _nativeConfirm = window.confirm;
const _nativePrompt = window.prompt;
window.confirm = undefined; // 禁用原生
window.prompt = undefined;
// ============================================================
// 登录系统
// ============================================================
// 密码哈希 (SHA-256 of the passphrase) — 在控制台用 setPassword('新密码') 可修改
async function hashStr(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
}
function isLoggedIn() {
const exp = localStorage.getItem('sp_login_expires');
if (!exp) return false;
if (Date.now() > parseInt(exp)) {
localStorage.removeItem('sp_login_expires');
return false;
}
return true;
}
async function doLogin() {
const input = document.getElementById('loginPass');
const err = document.getElementById('loginError');
err.textContent = '正在连接…';
const hash = await hashStr(input.value);
try {
// 服务端验证密码
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
const result = await resp.json();
if (result.ok) {
// 30天免登录
_origSetItem('sp_login_expires', String(Date.now() + 30 * 24 * 60 * 60 * 1000));
document.getElementById('loginOverlay').classList.add('hidden');
input.value = '';
err.textContent = '';
// 从服务器拉数据
await loadFromServer();
reloadAllData();
syncReady = true;
initApp();
} else {
err.textContent = '密码不对哦,再试试?';
input.value = '';
input.focus();
}
} catch(e) {
err.textContent = '连接服务器失败,请检查网络';
input.value = '';
}
}
function logout() {
localStorage.removeItem('sp_login_expires');
location.reload();
}
async function changePassword() {
const oldPass = await showDialog('请输入当前密码:', 'prompt');
if (!oldPass) return;
const newPass = await showDialog('请输入新密码:', 'prompt');
if (!newPass) return;
const confirmPass = await showDialog('再输入一次确认:', 'prompt');
if (newPass !== confirmPass) { alert('两次输入不一致'); return; }
try {
const oldHash = await hashStr(oldPass);
const newHash = await hashStr(newPass);
const resp = await fetch('/api/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldHash, newHash }),
});
const result = await resp.json();
if (result.ok) {
alert('密码修改成功!');
} else {
alert(result.error || '修改失败');
}
} catch(e) {
alert('连接服务器失败');
}
}
// 修改密码(在控制台调用)
async function setPassword(newPass) {
const h = await hashStr(newPass);
localStorage.setItem('sp_pass_hash', h);
console.log('密码已更新!');
}
// ============================================================
// 服务器数据同步
// ============================================================
// 所有需要同步的 key完整列表缺一个就可能丢数据
const SP_KEYS = [
'sp_modules','sp_schedule','sp_todos','sp_inbox','sp_reviews',
'sp_notes','sp_notes_deleted',
'sp_health_items','sp_health_plans','sp_health_checks','sp_health_diary',
'sp_music_items','sp_music_plans','sp_music_checks',
'sp_docs','sp_sleep','sp_sleep_deleted',
'sp_custom_reminders','sp_fixed_reminders','sp_pass_hash',
'sp_bugs','sp_goals','sp_checklists','sp_gym','sp_period',
'sp_dismissed_morning','sp_dismissed_health','sp_dismissed_sleep',
'sp_import_reading_v1','sp_import_sleep_v1','sp_fix_trumpet_v1','sp_fix_headtherapy_v1',
'sp_sleep_target','sp_buddy_username',
];
let syncReady = false;
let syncBusy = false; // 任何同步操作进行中
// 原始 localStorage 方法(不触发同步)——用原型方法,避免 bind 在某些手机浏览器上的问题
const _origSetItem = (k, v) => Storage.prototype.setItem.call(localStorage, k, v);
// ===== 数据保护层 =====
// 本地快照:每次从服务器拉数据前,先备份本地数据到 IndexedDB
// 即使 localStorage 被覆盖,也能从 IndexedDB 恢复
const _idb = { db: null };
function openBackupDB() {
return new Promise(resolve => {
if (_idb.db) { resolve(_idb.db); return; }
const req = indexedDB.open('planner_backup', 1);
req.onupgradeneeded = e => { e.target.result.createObjectStore('snapshots'); };
req.onsuccess = e => { _idb.db = e.target.result; resolve(_idb.db); };
req.onerror = () => resolve(null);
});
}
async function saveLocalSnapshot() {
try {
const db = await openBackupDB();
if (!db) return;
const data = collectLocalData();
const tx = db.transaction('snapshots', 'readwrite');
tx.objectStore('snapshots').put(data, 'latest');
tx.objectStore('snapshots').put(new Date().toISOString(), 'latest_time');
} catch(e) {}
}
async function getLocalSnapshot() {
try {
const db = await openBackupDB();
if (!db) return null;
return new Promise(resolve => {
const tx = db.transaction('snapshots', 'readonly');
const req = tx.objectStore('snapshots').get('latest');
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
} catch(e) { return null; }
}
// 从服务器拉取数据,带多重保护
async function loadFromServer() {
if (syncBusy) return false;
try {
// 1. 先把本地数据备份到 IndexedDB
await saveLocalSnapshot();
const resp = await fetch('/api/data');
if (!resp.ok) return false;
const data = await resp.json();
if (!data || Object.keys(data).length === 0) return false;
serverKeyCount = Object.keys(data).length;
for (const [k, v] of Object.entries(data)) {
const sv = typeof v === 'string' ? v : JSON.stringify(v);
// 唯一的保护:服务器数据为空时不覆盖本地已有数据
const local = localStorage.getItem(k);
if (local && local.length > 4) {
if (sv === '[]' || sv === '{}' || sv === '' || sv === 'null') continue;
}
_origSetItem(k, sv);
}
return true;
} catch(e) { return false; }
}
// 把本地数据推到服务器
function collectLocalData() {
const data = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k.startsWith('sp_')) data[k] = localStorage.getItem(k);
}
return data;
}
function setSyncStatus(text) {
const el = document.getElementById('syncStatus');
if (el) el.textContent = text;
}
let serverKeyCount = 0;
let pushRetryCount = 0;
let _retryTimer = null;
async function pushToServer() {
if (syncBusy) return false;
syncBusy = true;
setSyncStatus('⏳');
try {
const data = collectLocalData();
const keyCount = Object.keys(data).length;
// 安全检查:空浏览器不覆盖服务器
if (serverKeyCount > 5 && keyCount < 5) {
setSyncStatus('');
syncBusy = false;
return false;
}
const resp = await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.ok) {
setSyncStatus('✓');
pushRetryCount = 0;
syncBusy = false;
return true;
} else {
throw new Error('push failed');
}
} catch(e) {
setSyncStatus('✗ 未同步');
syncBusy = false;
// 推送失败自动重试最多5次指数退避
pushRetryCount++;
if (pushRetryCount <= 5) {
clearTimeout(_retryTimer);
const delay = Math.min(pushRetryCount * 10, 60) * 1000;
_retryTimer = setTimeout(() => pushToServer(), delay);
}
return false;
}
}
// 立刻推送
async function pushNow() {
clearTimeout(_dirtyTimer);
localDirty = false;
const ok = await pushToServer();
// 推送失败时重新标记 dirty让重试机制接管
if (!ok) localDirty = true;
}
// 标记本地有变更2秒后自动推送比5秒定时器更及时
let localDirty = false;
let _dirtyTimer = null;
function markLocalDirty() {
localDirty = true;
// 2秒内连续修改只推一次
clearTimeout(_dirtyTimer);
_dirtyTimer = setTimeout(() => {
if (localDirty && syncReady && !syncBusy && isLoggedIn()) {
localDirty = false;
pushToServer();
}
}, 2000);
}
async function manualSync() {
await pushToServer();
showToast('同步完成');
}
function exportAllData() {
const data = collectLocalData();
data._exportTime = new Date().toISOString();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `planner_backup_${new Date().toISOString().slice(0,10)}.json`;
a.click();
showToast('数据已导出');
}
async function recoverFromBackup() {
const snapshot = await getLocalSnapshot();
if (!snapshot) { showToast('没有本地备份'); return; }
const time = await new Promise(resolve => {
const db = _idb.db;
const tx = db.transaction('snapshots','readonly');
const req = tx.objectStore('snapshots').get('latest_time');
req.onsuccess = () => resolve(req.result || '未知');
req.onerror = () => resolve('未知');
});
if (!await showDialog(`找到本地备份(${time}),确定恢复?当前数据会被覆盖。`,'confirm')) return;
for (const [k, v] of Object.entries(snapshot)) {
_origSetItem(k, v);
}
reloadAllData(); refreshCurrentTab();
await pushToServer();
showToast('已从备份恢复');
}
function focusNoteInput() {
setTimeout(() => {
document.getElementById('notesCaptureInput')?.focus({ preventScroll: true });
window.scrollTo(0, 0);
}, 100);
}
function refreshCurrentTab() {
// 获取当前实际显示的子 tab
const group = TAB_GROUPS[currentTab];
const sub = group && group.length > 1 ? (activeSubTabs[currentTab] || group[0]) : (group ? group[0] : currentTab);
// 根据子 tab 刷新内容
if (sub === 'notes') { renderNotes(); focusNoteInput(); }
else if (sub === 'reminders') renderRemindersPanel();
else if (sub === 'todo') renderTodo();
else if (sub === 'health') renderHealth();
else if (sub === 'music') renderMusic();
else if (sub === 'sleep') renderSleep();
else if (sub === 'docs') renderDocs();
else if (sub === 'schedule') renderTimeline();
else if (sub === 'bugs') renderBugs();
else if (sub === 'gym') renderGym();
else if (sub === 'period') renderPeriod();
else if (sub === 'goals') renderGoals();
else if (sub === 'checklists') renderChecklists();
else if (sub === 'template') renderTemplate();
else if (sub === 'review') renderReview();
}
// 页面加载时检查登录状态
if (isLoggedIn()) {
document.getElementById('loginOverlay').classList.add('hidden');
}
// ============================================================
// 共用
// ============================================================
const COLORS = [
{ bg:'#e8f5e9', fg:'#2e7d32' }, { bg:'#e3f2fd', fg:'#1565c0' },
{ bg:'#fff3e0', fg:'#e65100' }, { bg:'#fce4ec', fg:'#c62828' },
{ bg:'#f3e5f5', fg:'#7b1fa2' }, { bg:'#e0f7fa', fg:'#00838f' },
{ bg:'#fff9c4', fg:'#f9a825' }, { bg:'#efebe9', fg:'#4e342e' },
];
const ALL_EMOJIS = ['🌿','💬','📚','🏃','🍱','😴','📱','🤝','⭐','📌','🎯','💡','🔔','✏️','🎵','🧘','🍵','📊','🎨','💼','🛒','🧹','✈️','💪','🎬','☕','🍳','📧'];
function dateKey(d) { return d.toISOString().slice(0,10); }
function formatDate(d) {
const days = ['周日','周一','周二','周三','周四','周五','周六'];
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}${days[d.getDay()]}`;
}
// ============================================================
// Tab 切换
// ============================================================
let currentTab = localStorage.getItem('sp_current_tab') || 'notes';
// 旧 tab 名迁移到新名(在 TAB_GROUPS 定义之后执行,见下方 _initTabRestore
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
switchToTab(btn.dataset.tab);
});
});
// ===== 侧边栏 =====
// Tab 合并映射:主 tab → 子 tab 列表
const TAB_GROUPS = {
notes: ['notes'],
tasks: ['todo', 'goals', 'checklists'],
reminders: ['reminders'],
body: ['health', 'sleep', 'gym', 'period'],
music: ['music'],
docs: ['docs'],
planning: ['schedule', 'template', 'review'],
};
const SUB_TAB_NAMES = {todo:'待办',goals:'目标',checklists:'清单',health:'健康打卡',sleep:'睡眠',gym:'健身',period:'经期',schedule:'日程',template:'模板',review:'回顾'};
const TAB_NAMES = {notes:'随手记',tasks:'待办',reminders:'提醒',body:'身体',music:'音乐',docs:'文档',planning:'日程',bugs:'Bug'};
const TAB_ICONS = {notes:'📝',tasks:'✅',reminders:'⏰',body:'💪',music:'🎵',docs:'📄',planning:'📅',bugs:'🐛'};
// 当前选中的子 tab
let activeSubTabs = { tasks:'todo', body:'health', planning:'schedule' };
// 恢复上次的 TabTAB_GROUPS 已定义,可以安全调用)
(function _initTabRestore() {
const _tabMigrate = {todo:'tasks',health:'body',sleep:'body',goals:'tasks',checklists:'tasks',bugs:'notes',gym:'body',period:'body',schedule:'planning',template:'planning',review:'planning'};
if (_tabMigrate[currentTab]) {
activeSubTabs[_tabMigrate[currentTab]] = currentTab;
currentTab = _tabMigrate[currentTab];
}
if (currentTab !== 'notes') {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === currentTab));
showCurrentTab();
}
})();
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const mask = document.getElementById('sidebarMask');
const open = sb.style.display === 'none';
if (open) {
let html = '';
for (const [tab, name] of Object.entries(TAB_NAMES)) {
if (tab === 'bugs') continue; // Bug 在菜单里,不在侧边栏
const active = tab === currentTab;
html += `<div onclick="switchToTab('${tab}');toggleSidebar();" style="padding:12px 20px;cursor:pointer;font-size:14px;display:flex;align-items:center;gap:10px;${active?'background:#f0f0ff;color:#667eea;font-weight:600;':'color:#555;'}">${TAB_ICONS[tab]||''} ${name}</div>`;
// 展开当前组的子标签
if (active && TAB_GROUPS[tab] && TAB_GROUPS[tab].length > 1) {
const sub = activeSubTabs[tab] || TAB_GROUPS[tab][0];
TAB_GROUPS[tab].forEach(s => {
const isActive = s === sub;
html += `<div onclick="switchToTab('${s}');toggleSidebar();" style="padding:8px 20px 8px 40px;cursor:pointer;font-size:13px;${isActive?'color:#667eea;font-weight:500;':'color:#999;'}">${SUB_TAB_NAMES[s]||s}</div>`;
});
}
}
html += `<div style="border-top:1px solid #f0f0f0;margin:8px 0;"></div>`;
html += `<div onclick="switchToTab('bugs');toggleSidebar();" style="padding:12px 20px;cursor:pointer;font-size:14px;display:flex;align-items:center;gap:10px;color:#555;">🐛 Bug</div>`;
html += `<div onclick="openGlobalSearch();toggleSidebar();" style="padding:12px 20px;cursor:pointer;font-size:14px;display:flex;align-items:center;gap:10px;color:#555;">🔍 全局搜索</div>`;
sb.innerHTML = html;
sb.style.display = 'block'; mask.style.display = 'block';
} else {
sb.style.display = 'none'; mask.style.display = 'none';
}
}
function switchToTab(tab) {
// Bug 是特殊的独立 tab不在主导航里
if (tab === 'bugs') {
currentTab = 'bugs';
localStorage.setItem('sp_current_tab', 'bugs');
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-bugs')?.classList.add('active');
renderBugs();
return;
}
// 如果 tab 是子 tab如 'sleep'),找到它的主 tab
let mainTab = tab;
for (const [main, subs] of Object.entries(TAB_GROUPS)) {
if (subs.includes(tab)) { mainTab = main; activeSubTabs[main] = tab; break; }
}
currentTab = mainTab;
localStorage.setItem('sp_current_tab', currentTab);
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === currentTab));
showCurrentTab();
updateHeaderActions();
refreshCurrentTab();
}
function showCurrentTab() {
// 先移除子标签栏
document.getElementById('subTabBar')?.remove();
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const group = TAB_GROUPS[currentTab];
if (group && group.length > 1) {
const subTab = activeSubTabs[currentTab] || group[0];
const el = document.getElementById('tab-' + subTab);
if (el) { el.classList.add('active'); renderSubTabs(currentTab, group, subTab); }
else { document.getElementById('tab-notes')?.classList.add('active'); } // 兜底
} else if (group) {
document.getElementById('tab-' + group[0])?.classList.add('active');
} else {
// 独立 tab如 bugs或未知 tab
const el = document.getElementById('tab-' + currentTab);
if (el) el.classList.add('active');
else document.getElementById('tab-notes')?.classList.add('active');
}
}
function renderSubTabs(mainTab, subs, activeSub) {
// 在当前显示的 tab-content 顶部插入子标签栏
const el = document.getElementById('tab-' + activeSub);
if (!el) return;
let bar = document.getElementById('subTabBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'subTabBar';
bar.style.cssText = 'display:flex;gap:6px;padding:8px 16px;overflow-x:auto;background:#f8f8fc;border-bottom:1px solid #f0f0f0;position:sticky;top:0;z-index:10;';
}
bar.innerHTML = subs.map(s =>
`<button onclick="activeSubTabs['${mainTab}']='${s}';showCurrentTab();refreshCurrentTab();" style="padding:6px 14px;border-radius:20px;border:none;font-size:13px;cursor:pointer;white-space:nowrap;${s===activeSub?'background:#667eea;color:white;font-weight:500;':'background:white;color:#888;border:1px solid #e0e0e0;'}">${SUB_TAB_NAMES[s]||s}</button>`
).join('');
el.prepend(bar);
}
// ===== 全局搜索 =====
function openGlobalSearch() {
const html = `<div style="padding:0;">
<input type="text" id="globalSearchInput" placeholder="搜索笔记、待办、Bug、文档…" style="width:100%;padding:12px 16px;border:none;border-bottom:1px solid #f0f0f0;font-size:15px;outline:none;box-sizing:border-box;" oninput="doGlobalSearch(this.value)" onkeydown="if(event.key==='Escape')closeDialog()">
<div id="globalSearchResults" style="max-height:60vh;overflow-y:auto;padding:8px;"></div>
</div>`;
// reuse showDialog's overlay
let overlay = document.getElementById('dialogOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'dialogOverlay';
overlay.style.cssText = 'display:flex;position:fixed;inset:0;z-index:999;align-items:flex-start;justify-content:center;background:rgba(0,0,0,0.4);padding-top:80px;';
document.body.appendChild(overlay);
}
overlay.innerHTML = `<div style="background:white;border-radius:14px;width:90%;max-width:460px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.15);" onclick="event.stopPropagation()">${html}</div>`;
overlay.style.display = 'flex';
overlay.onclick = () => { overlay.style.display = 'none'; };
setTimeout(() => document.getElementById('globalSearchInput')?.focus(), 100);
}
function doGlobalSearch(q) {
const el = document.getElementById('globalSearchResults');
if (!q || q.length < 1) { el.innerHTML = '<div style="color:#ccc;text-align:center;padding:20px;font-size:13px;">输入关键词开始搜索</div>'; return; }
q = q.toLowerCase();
const results = [];
// 搜笔记
const notes = JSON.parse(localStorage.getItem('sp_notes') || '[]');
notes.filter(n => n.text?.toLowerCase().includes(q)).slice(0, 8).forEach(n => {
results.push({ icon: '📝', tab: 'notes', text: n.text, sub: n.time || '' });
});
// 搜待办
const _td = JSON.parse(localStorage.getItem('sp_todos') || '{}');
['q1','q2','q3','q4'].forEach(qk => {
(_td[qk]||[]).filter(t => t.text?.toLowerCase().includes(q)).slice(0, 4).forEach(t => {
results.push({ icon: '✅', tab: 'todo', text: t.text, sub: t.done ? '已完成' : '' });
});
});
// 搜 Bug
const allBugs = JSON.parse(localStorage.getItem('sp_bugs') || '[]');
allBugs.filter(b => b.text?.toLowerCase().includes(q)).slice(0, 6).forEach(b => {
results.push({ icon: '🐛', tab: 'bugs', text: b.text, sub: b.time || '' });
});
// 搜文档
const docs = JSON.parse(localStorage.getItem('sp_docs') || '[]');
docs.forEach(doc => {
(doc.entries || []).filter(e => e.text?.toLowerCase().includes(q)).slice(0, 4).forEach(e => {
results.push({ icon: doc.icon, tab: 'docs', text: e.text, sub: doc.name });
});
});
if (!results.length) { el.innerHTML = '<div style="color:#ccc;text-align:center;padding:20px;font-size:13px;">没有找到</div>'; return; }
el.innerHTML = results.slice(0, 20).map(r =>
`<div onclick="document.getElementById('dialogOverlay').style.display='none';switchToTab('${r.tab}');" style="padding:10px 12px;border-radius:8px;cursor:pointer;font-size:13px;display:flex;align-items:flex-start;gap:8px;" onmouseover="this.style.background='#f5f5ff'" onmouseout="this.style.background=''">
<span>${r.icon}</span>
<div style="flex:1;min-width:0;">
<div style="color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(r.text)}</div>
<div style="font-size:11px;color:#bbb;margin-top:2px;">${escHtml(r.sub)}</div>
</div>
</div>`
).join('');
}
function updateHeaderActions() {
const el = document.getElementById('headerActions');
let extra = '';
// schedule buttons moved to date-nav area
el.innerHTML = `${extra}<button class="header-menu-btn" onclick="toggleHeaderMenu(event)">⋮</button>`;
// 确保遮罩和下拉菜单存在(放在 body 上,不受 header 层叠限制)
if (!document.getElementById('dropdownMask')) {
const mask = document.createElement('div');
mask.id = 'dropdownMask';
mask.className = 'dropdown-mask';
mask.onclick = closeHeaderMenu;
document.body.appendChild(mask);
const dd = document.createElement('div');
dd.id = 'headerDropdown';
dd.className = 'header-dropdown';
dd.innerHTML = `
<button onclick="location.reload()">刷新页面</button>
<button onclick="manualSync();closeHeaderMenu()">同步数据</button>
<button onclick="changePassword();closeHeaderMenu()">修改密码</button>
<button onclick="switchToTab('bugs');closeHeaderMenu()">🐛 Bug 追踪</button>
<button onclick="manageBuddyUsers();closeHeaderMenu()">管理睡眠用户</button>
<button onclick="exportAllData();closeHeaderMenu()">导出所有数据</button>
<button onclick="recoverFromBackup();closeHeaderMenu()">恢复本地备份</button>
<button class="dd-danger" onclick="logout()">退出登录</button>`;
document.body.appendChild(dd);
}
}
function toggleHeaderMenu(e) {
e.stopPropagation();
const dd = document.getElementById('headerDropdown');
const mask = document.getElementById('dropdownMask');
const isOpen = dd.classList.contains('open');
dd.classList.toggle('open', !isOpen);
mask.classList.toggle('open', !isOpen);
}
function closeHeaderMenu() {
document.getElementById('headerDropdown')?.classList.remove('open');
document.getElementById('dropdownMask')?.classList.remove('open');
}
updateHeaderActions();
// ============================================================
// 1. 日程规划(原有功能)
// ============================================================
const DEFAULT_MODULES = [
{ name:'精油调理', emoji:'🌿', color:0 },
{ name:'客户沟通', emoji:'💬', color:1 },
{ name:'学习充电', emoji:'📚', color:2 },
{ name:'运动健身', emoji:'🏃', color:3 },
{ name:'午餐', emoji:'🍱', color:6 },
{ name:'休息', emoji:'😴', color:4 },
{ name:'社交媒体', emoji:'📱', color:5 },
{ name:'会议', emoji:'🤝', color:7 },
];
let modules = JSON.parse(localStorage.getItem('sp_modules')) || DEFAULT_MODULES;
let currentDate = new Date();
let selectedColor = 0;
let scheduleData = JSON.parse(localStorage.getItem('sp_schedule')) || {};
function saveSchedule() {
localStorage.setItem('sp_modules', JSON.stringify(modules));
localStorage.setItem('sp_schedule', JSON.stringify(scheduleData));
markLocalDirty();
}
function renderModules() {
const list = document.getElementById('moduleList');
list.innerHTML = '';
modules.forEach((m, i) => {
const div = document.createElement('div');
div.className = 'module-item';
div.style.background = COLORS[m.color].bg;
div.style.color = COLORS[m.color].fg;
div.draggable = true;
div.innerHTML = `
<span class="emoji">${m.emoji}</span><span>${m.name}</span>
<div class="module-actions">
<button onclick="event.stopPropagation();openEdit(${i})" title="编辑">✎</button>
<button class="delete-module" onclick="event.stopPropagation();deleteModule(${i})" title="删除">×</button>
</div>`;
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', JSON.stringify({ type:'module', index:i }));
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
list.appendChild(div);
});
}
function renderColorPicker() {
const row = document.getElementById('colorPicker');
row.innerHTML = '';
COLORS.forEach((c,i) => {
const dot = document.createElement('div');
dot.className = 'color-dot';
dot.style.background = c.bg;
dot.style.border = i===selectedColor ? `2.5px solid ${c.fg}` : '2.5px solid transparent';
dot.onclick = () => { selectedColor=i; renderColorPicker(); };
row.appendChild(dot);
});
}
function renderTimeline() {
document.getElementById('dateLabel').textContent = formatDate(currentDate);
const container = document.getElementById('timeSlots');
container.innerHTML = '';
const dk = dateKey(currentDate);
const dayData = scheduleData[dk] || {};
for (let h=6; h<=23; h++) {
const row = document.createElement('div');
row.className = 'time-slot';
const label = document.createElement('div');
label.className = 'time-label';
label.textContent = `${h.toString().padStart(2,'0')}:00`;
const drop = document.createElement('div');
drop.className = 'slot-drop';
drop.dataset.hour = h;
(dayData[h]||[]).forEach((item, idx) => {
const chip = document.createElement('div');
chip.className = 'placed-item';
chip.style.background = COLORS[item.color].bg;
chip.style.color = COLORS[item.color].fg;
chip.draggable = true;
chip.innerHTML = `<span>${item.emoji}</span><span>${item.name}</span>
<button class="remove-btn" onclick="event.stopPropagation();removeItem('${dk}',${h},${idx})">×</button>`;
chip.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', JSON.stringify({ type:'placed', date:dk, hour:h, index:idx }));
chip.classList.add('dragging');
});
chip.addEventListener('dragend', () => chip.classList.remove('dragging'));
drop.appendChild(chip);
});
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag-over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
drop.addEventListener('drop', e => {
e.preventDefault();
drop.classList.remove('drag-over');
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.type==='module') {
const m = modules[data.index];
if (!scheduleData[dk]) scheduleData[dk]={};
if (!scheduleData[dk][h]) scheduleData[dk][h]=[];
scheduleData[dk][h].push({ name:m.name, emoji:m.emoji, color:m.color });
} else if (data.type==='placed') {
const src = scheduleData[data.date]?.[data.hour];
if (src) {
const [moved] = src.splice(data.index, 1);
if (!scheduleData[dk]) scheduleData[dk]={};
if (!scheduleData[dk][h]) scheduleData[dk][h]=[];
scheduleData[dk][h].push(moved);
}
}
saveSchedule(); renderTimeline(); pushNow();
});
row.appendChild(label); row.appendChild(drop);
container.appendChild(row);
}
}
function addModule() {
const input = document.getElementById('newModuleName');
const name = input.value.trim();
if (!name) return;
const emoji = ALL_EMOJIS[modules.length % ALL_EMOJIS.length];
modules.push({ name, emoji, color:selectedColor });
input.value = '';
saveSchedule(); renderModules(); pushNow();
}
async function deleteModule(i) { if(!await showDialog(`确定删除「${modules[i].name}」?`,'confirm'))return; modules.splice(i,1); saveSchedule(); renderModules(); pushNow(); }
function removeItem(dk,h,idx) { scheduleData[dk]?.[h]?.splice(idx,1); saveSchedule(); renderTimeline(); pushNow(); }
function changeDate(d) { currentDate.setDate(currentDate.getDate()+d); renderTimeline(); }
function clearAll() { delete scheduleData[dateKey(currentDate)]; saveSchedule(); renderTimeline(); pushNow(); }
function exportSchedule() {
const dk = dateKey(currentDate);
const dayData = scheduleData[dk]||{};
let text = `📅 ${formatDate(currentDate)}\n${'─'.repeat(24)}\n`;
let has = false;
for (let h=6;h<=23;h++) {
const items = dayData[h]||[];
if (items.length) {
has = true;
text += `${h.toString().padStart(2,'0')}:00 ${items.map(i=>`${i.emoji} ${i.name}`).join(' ')}\n`;
}
}
if (!has) text += '\n今天还没有安排活动哦';
document.getElementById('exportText').textContent = text;
document.getElementById('modalOverlay').classList.add('open');
}
function copyExport() {
navigator.clipboard.writeText(document.getElementById('exportText').textContent).then(() => {
const btn = event.target;
btn.textContent = '已复制 ✓';
setTimeout(() => btn.textContent = '复制到剪贴板', 1500);
});
}
// 编辑模块
let editingIndex=-1, editColor=0, editEmoji='';
function openEdit(i) {
editingIndex=i; const m=modules[i]; editColor=m.color; editEmoji=m.emoji;
document.getElementById('editName').value = m.name;
renderEditEmojis(); renderEditColors();
document.getElementById('editOverlay').classList.add('open');
document.getElementById('editName').focus();
}
function renderEditEmojis() {
const row = document.getElementById('editEmojiRow'); row.innerHTML='';
ALL_EMOJIS.forEach(e => {
const d = document.createElement('div');
d.className = 'emoji-option'+(e===editEmoji?' selected':'');
d.textContent = e;
d.onclick = () => { editEmoji=e; renderEditEmojis(); };
row.appendChild(d);
});
}
function renderEditColors() {
const row = document.getElementById('editColorRow'); row.innerHTML='';
COLORS.forEach((c,i) => {
const d = document.createElement('div');
d.className = 'color-dot';
d.style.background = c.bg;
d.style.border = i===editColor?`2.5px solid ${c.fg}`:'2.5px solid transparent';
d.onclick = () => { editColor=i; renderEditColors(); };
row.appendChild(d);
});
}
function saveEdit() {
const name = document.getElementById('editName').value.trim();
if (!name||editingIndex<0) return;
modules[editingIndex] = { ...modules[editingIndex], name, color:editColor, emoji:editEmoji };
saveSchedule(); renderModules(); closeEdit(); pushNow();
}
function closeEdit() { editingIndex=-1; document.getElementById('editOverlay').classList.remove('open'); }
// ============================================================
// 2. 每周模板(来自 schedule-tool.jsx
// ============================================================
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 = ['周一','周二','周三','周四','周五'];
let selectedTemplateDay = DAYS[Math.min(Math.max(new Date().getDay()-1, 0), 4)];
function renderTemplate() {
// day tabs
const tabs = document.getElementById('dayTabs');
tabs.innerHTML = '';
DAYS.forEach(d => {
const btn = document.createElement('button');
btn.className = 'day-btn' + (d===selectedTemplateDay?' active':'');
btn.textContent = d;
btn.onclick = () => { selectedTemplateDay=d; renderTemplate(); };
tabs.appendChild(btn);
});
const sched = WEEK_SCHEDULE[selectedTemplateDay];
if (!sched) return;
document.getElementById('dayNote').innerHTML = `<div class="day-note">💡 ${sched.note}</div>`;
const tl = document.getElementById('dayTimeline');
tl.innerHTML = '';
sched.fixed.forEach(item => {
const st = TYPE_STYLE[item.type];
tl.innerHTML += `
<div class="tl-item">
<div class="tl-time">${item.time}</div>
<div class="tl-dot-col"><div class="tl-dot" style="background:${st.dot}"></div></div>
<div class="tl-card" style="${item.type==='sleep'?'background:#f1f5f9;border-color:#cbd5e1':''}">
<span style="font-weight:${item.type==='sleep'?500:400}">${item.task}</span>
<span class="tl-badge" style="background:${st.bg};color:${st.dot}">${st.label}</span>
</div>
</div>`;
});
}
// ============================================================
// 3. 待办事项(四象限)
// ============================================================
const QUADRANTS = [
{ key:'q1', title:'本日', desc:'今天必须做', cls:'q-urgent-important', icon:'❗' },
{ key:'q2', title:'本周', desc:'这周内完成', cls:'q-important', icon:'⭐' },
{ key:'q3', title:'本月', desc:'这个月做完', cls:'q-urgent', icon:'⏳' },
{ key:'q4', title:'慢慢来', desc:'不着急,有空再说', cls:'q-neither', icon:'…' },
];
let todos = JSON.parse(localStorage.getItem('sp_todos')) || { q1:[], q2:[], q3:[], q4:[] };
let inbox = JSON.parse(localStorage.getItem('sp_inbox')) || [];
function saveTodos() { localStorage.setItem('sp_todos', JSON.stringify(todos)); markLocalDirty(); }
function saveInbox() { localStorage.setItem('sp_inbox', JSON.stringify(inbox)); markLocalDirty(); }
function addInboxItem() {
const input = document.getElementById('inboxInput');
const rawText = input.value.trim();
if (!rawText) return;
const timeStr = new Date().toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' });
const pieces = rawText.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean);
pieces.forEach((text, i) => {
if (!inbox.some(item => item.text === text)) {
inbox.push({ text, id: Date.now() + i, time: timeStr });
}
});
input.value = '';
saveInbox(); renderInbox(); pushNow();
input.focus();
}
function moveToQuadrant(inboxIdx, qKey) {
const item = inbox[inboxIdx];
if (!item) return;
todos[qKey].push({ text: item.text, done: false, id: Date.now() });
inbox.splice(inboxIdx, 1);
saveInbox(); saveTodos(); renderInbox(); renderTodo(); pushNow();
}
async function deleteInboxItem(idx) {
if (!await showDialog(`确定删除「${inbox[idx]?.text}」?`,'confirm')) return;
inbox.splice(idx, 1);
saveInbox(); renderInbox(); pushNow();
}
async function clearInbox() {
if (!await showDialog(`确定清空收集箱的 ${inbox.length} 条?`,'confirm')) return;
inbox = [];
saveInbox(); renderInbox(); pushNow();
}
function renderInbox() {
const list = document.getElementById('inboxList');
const clearBtn = document.getElementById('inboxClearBtn');
list.innerHTML = '';
if (clearBtn) clearBtn.style.display = inbox.length > 0 ? 'block' : 'none';
if (inbox.length === 0) return;
inbox.forEach((item, i) => {
list.innerHTML += `
<div class="inbox-item">
<span class="inbox-text">${escHtml(item.text)}</span>
<span class="inbox-time">${item.time || ''}</span>
<div class="inbox-move-btns" title="分类到四象限">
<button class="inbox-move-btn to-q1" onclick="moveToQuadrant(${i},'q1')" title="❗ 本日">!</button>
<button class="inbox-move-btn to-q2" onclick="moveToQuadrant(${i},'q2')" title="⭐ 本周">★</button>
<button class="inbox-move-btn to-q3" onclick="moveToQuadrant(${i},'q3')" title="⏳ 本月">→</button>
<button class="inbox-move-btn to-q4" onclick="moveToQuadrant(${i},'q4')" title="… 慢慢来">…</button>
</div>
<button class="inbox-del-btn" onclick="deleteInboxItem(${i})">×</button>
</div>`;
});
}
function renderTodo() {
renderInbox();
// stats
const all = [...todos.q1,...todos.q2,...todos.q3,...todos.q4];
const done = all.filter(t=>t.done).length;
const total = all.length;
const inboxCount = inbox.length;
document.getElementById('todoStats').innerHTML = `
<div class="stat-card"><div class="stat-num">${inboxCount}</div><div class="stat-label">收集箱</div></div>
<div class="stat-card"><div class="stat-num">${total-done}</div><div class="stat-label">待处理</div></div>
<div class="stat-card"><div class="stat-num">${done}</div><div class="stat-label">已完成</div></div>
<div class="stat-card"><div class="stat-num">${total?Math.round(done/total*100):0}%</div><div class="stat-label">完成率</div></div>
`;
// quadrants
const grid = document.getElementById('quadrantGrid');
grid.innerHTML = '';
const showDone = document.getElementById('todoShowDone')?.checked || false;
const searchText = (document.getElementById('todoSearch')?.value || '').trim().toLowerCase();
QUADRANTS.forEach(q => {
const items = todos[q.key] || [];
let html = `<div class="quadrant ${q.cls}" data-qkey="${q.key}"
ondragover="event.preventDefault();this.style.boxShadow='0 0 0 2px #667eea'"
ondragleave="this.style.boxShadow=''"
ondrop="this.style.boxShadow='';dropTodo(event,'${q.key}')">
<div class="quadrant-title">${q.icon} ${q.title}</div>
<div class="quadrant-desc">${q.desc}</div>
<div class="todo-list">`;
items.forEach((t,i) => {
// 隐藏已完成
if (t.done && !showDone) return;
// 搜索过滤
if (searchText && !t.text.toLowerCase().includes(searchText)) return;
const overdue = t.deadline && !t.done && t.deadline < dateKey(new Date());
const deadlineHtml = t.deadline
? `<span class="todo-deadline ${overdue?'overdue':''}" onclick="setDeadline('${q.key}',${i})" title="点击修改">${t.deadline.slice(5)}</span>`
: `<button class="todo-deadline-add" onclick="setDeadline('${q.key}',${i})" title="设置截止日期">+日期</button>`;
html += `<div class="todo-item" draggable="true"
ondragstart="event.dataTransfer.setData('text/plain',JSON.stringify({from:'${q.key}',idx:${i}}))"
ontouchstart="todoTouchStart(event,'${q.key}',${i})" ontouchend="todoTouchEnd(event)" ontouchmove="todoTouchMove(event)">
<button class="todo-check ${t.done?'done':''}" onclick="toggleTodo('${q.key}',${i})">
${t.done?'✓':''}
</button>
<span class="todo-text ${t.done?'done':''}" style="flex:1;" ondblclick="copyText(this.textContent)" title="双击复制">${escHtml(t.text)}</span>
${deadlineHtml}
<button class="todo-del" onclick="deleteTodo('${q.key}',${i})">×</button>
</div>`;
});
html += `</div>
<div class="todo-add-row">
<textarea placeholder="添加任务…多条用空行分隔" id="input-${q.key}" rows="1"
style="resize:none;min-height:32px;"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();addTodo('${q.key}')}"></textarea>
<button onclick="addTodo('${q.key}')">+</button>
</div>
</div>`;
grid.innerHTML += html;
});
}
function copyText(text) {
navigator.clipboard.writeText(text.trim()).then(() => {
showRouteToast(['已复制']);
}).catch(() => {});
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function showToast(msg) {
let t = document.getElementById('_toast');
if (!t) { t = document.createElement('div'); t.id = '_toast'; t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:white;padding:8px 20px;border-radius:20px;font-size:13px;z-index:9999;pointer-events:none;transition:opacity 0.3s;'; document.body.appendChild(t); }
t.textContent = msg; t.style.opacity = '1';
clearTimeout(t._tid); t._tid = setTimeout(() => { t.style.opacity = '0'; }, 1800);
}
function addTodo(qKey) {
const input = document.getElementById('input-'+qKey);
const rawText = input.value.trim();
if (!rawText) return;
const pieces = rawText.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean);
pieces.forEach((text, i) => {
const allTodos = [...(todos.q1||[]),...(todos.q2||[]),...(todos.q3||[]),...(todos.q4||[])];
if (!allTodos.some(t => t.text === text)) {
todos[qKey].push({ text, done:false, id: Date.now() + i });
}
});
input.value = '';
saveTodos(); renderTodo(); pushNow();
setTimeout(() => document.getElementById('input-'+qKey)?.focus(), 50);
}
async function setDeadline(qKey, idx) {
const t = todos[qKey][idx];
const current = t.deadline || dateKey(new Date());
const val = await showDialog('设置截止日期', 'date', current);
if (val === null) return;
const deadlineDate = val.trim();
todos[qKey][idx].deadline = deadlineDate;
saveTodos(); renderTodo(); pushNow();
// 自动创建截止日期当天早上8点的提醒
if (deadlineDate) {
const reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
// 检查是否已有这个任务的提醒
const exists = reminders.some(r => r.content === t.text && r.date === deadlineDate);
if (!exists) {
reminders.push({
id: 'custom_' + Date.now(),
date: deadlineDate,
time: '08:00',
hour: 8, minute: 0,
content: t.text,
timeLabel: `${deadlineDate.slice(5)} 08:00`,
dismissed: false,
});
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
pushNow();
}
}
}
// 拖放移动待办
function dropTodo(event, toKey) {
try {
const data = JSON.parse(event.dataTransfer.getData('text/plain'));
moveTodoItem(data.from, data.idx, toKey);
} catch(e) {}
}
function moveTodoItem(fromKey, idx, toKey) {
if (fromKey === toKey) return;
const item = todos[fromKey]?.[idx];
if (!item) return;
todos[fromKey].splice(idx, 1);
todos[toKey].push(item);
saveTodos(); renderTodo(); pushNow();
}
// 手机长按移动
let todoLPTimer = null, todoLPTriggered = false;
function todoTouchStart(e, qKey, idx) {
todoLPTriggered = false;
todoLPTimer = setTimeout(() => {
todoLPTriggered = true;
showTodoMoveMenu(qKey, idx);
}, 500);
}
function todoTouchEnd(e) { if (todoLPTimer) { clearTimeout(todoLPTimer); todoLPTimer = null; } }
function todoTouchMove(e) { if (todoLPTimer) { clearTimeout(todoLPTimer); todoLPTimer = null; } }
function showTodoMoveMenu(fromKey, idx) {
const item = todos[fromKey]?.[idx];
if (!item) return;
document.getElementById('poolActionSheet')?.remove();
document.getElementById('poolActionMask')?.remove();
const labels = { q1:'❗ 本日', q2:'⭐ 本周', q3:'⏳ 本月', q4:'… 慢慢来' };
const sheet = document.createElement('div');
sheet.id = 'poolActionSheet';
sheet.className = 'pool-item-actions-mobile open';
sheet.innerHTML = `
<div style="text-align:center;font-size:13px;color:#999;margin-bottom:10px;">移动到…</div>
${Object.entries(labels).filter(([k])=>k!==fromKey).map(([k,l]) =>
`<button onclick="document.getElementById('poolActionSheet').remove();document.getElementById('poolActionMask').remove();moveTodoItem('${fromKey}',${idx},'${k}')">${l}</button>`
).join('')}
<button class="pam-cancel" onclick="document.getElementById('poolActionSheet').remove();document.getElementById('poolActionMask').remove()">取消</button>
`;
document.body.appendChild(sheet);
const mask = document.createElement('div');
mask.id = 'poolActionMask';
mask.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.15);';
mask.onclick = () => { sheet.remove(); mask.remove(); };
document.body.appendChild(mask);
}
function toggleTodo(qKey, idx) {
const t = todos[qKey][idx];
t.done = !t.done;
// 如果标记完成且有截止日期提醒,把提醒也标记为 dismissed
if (t.done && t.deadline) {
const reminders = JSON.parse(localStorage.getItem('sp_custom_reminders') || '[]');
reminders.forEach(r => {
if (r.content === t.text && r.date === t.deadline) r.dismissed = true;
});
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
}
saveTodos(); renderTodo(); pushNow();
}
async function deleteTodo(qKey, idx) {
if (!await showDialog(`确定删除「${todos[qKey][idx]?.text}」?`,'confirm')) return;
todos[qKey].splice(idx, 1);
saveTodos(); renderTodo(); pushNow();
}
// ============================================================
// 4. 周回顾
// ============================================================
const REVIEW_FIELDS = [
{ key:'sleep', label:'😴 这周睡眠情况', placeholder:'实际几点睡的?有没有改善?' },
{ key:'wins', label:'✨ 做得好的地方', placeholder:'哪些时间安排运转良好?' },
{ key:'issues',label:'🔧 遇到的问题', placeholder:'哪里卡住了?什么原因?' },
{ key:'next', label:'🎯 下周调整计划', placeholder:'下周想改变什么?具体怎么做?' },
];
let reviews = JSON.parse(localStorage.getItem('sp_reviews')) || {};
function saveReviews() { localStorage.setItem('sp_reviews', JSON.stringify(reviews)); markLocalDirty(); }
function renderReview() {
const form = document.getElementById('reviewForm');
form.innerHTML = '';
REVIEW_FIELDS.forEach(f => {
const val = document.getElementById('rv-'+f.key)?.value || '';
form.innerHTML += `
<div style="margin-bottom:14px;">
<div style="font-size:14px;font-weight:500;margin-bottom:6px;">${f.label}</div>
<textarea class="review-textarea" id="rv-${f.key}" placeholder="${f.placeholder}">${val}</textarea>
</div>`;
});
const hist = document.getElementById('reviewHistory');
const keys = Object.keys(reviews).sort().reverse();
if (keys.length === 0) {
hist.innerHTML = '<div style="text-align:center;color:#ccc;padding:20px;">还没有回顾记录</div>';
return;
}
hist.innerHTML = '';
keys.forEach(k => {
const r = reviews[k];
let html = `<div class="review-history-item">
<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
<span style="background:#f0f0f0;padding:2px 10px;border-radius:6px;font-size:12px;">${k}</span>
<span style="font-size:12px;color:#aaa;">${r.date||''}</span>
</div>`;
if (r.sleep) html += `<div class="rh-label">睡眠</div><div class="rh-text">${escHtml(r.sleep)}</div>`;
if (r.wins) html += `<div class="rh-label">亮点</div><div class="rh-text">${escHtml(r.wins)}</div>`;
if (r.issues)html += `<div class="rh-label">问题</div><div class="rh-text">${escHtml(r.issues)}</div>`;
if (r.next) html += `<div class="rh-label">下周</div><div class="rh-text">${escHtml(r.next)}</div>`;
html += '</div>';
hist.innerHTML += html;
});
}
function saveReview() {
const weekNum = getWeekNumber(new Date());
const key = `${new Date().getFullYear()}-W${weekNum}`;
const data = {};
REVIEW_FIELDS.forEach(f => {
data[f.key] = document.getElementById('rv-'+f.key)?.value || '';
});
data.date = formatDate(new Date());
reviews[key] = data;
saveReviews(); pushNow();
// 清空表单
REVIEW_FIELDS.forEach(f => {
const el = document.getElementById('rv-'+f.key);
if (el) el.value = '';
});
renderReview();
}
function getWeekNumber(d) {
const onejan = new Date(d.getFullYear(),0,1);
return Math.ceil(((d - onejan) / 86400000 + onejan.getDay()+1) / 7);
}
// ============================================================
// 5. 随手记
// ============================================================
let notesFilter = 'all';
let noteTag = '灵感';
const TAG_ICONS = { '灵感':'💡', '提醒':'⏰', '目标':'🎯', '读书':'📖', '待办':'✅', '睡眠':'🌙', '健康':'💊', '音乐':'🎵', '健身':'💪', '经期':'🌸', 'Bug':'🐛' };
let manualTagPicked = false;
function pickNoteTag(btn) {
document.querySelectorAll('.notes-tag-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
noteTag = btn.dataset.tag;
manualTagPicked = true;
}
function autoHighlightTag(text) {
if (manualTagPicked) return; // 用户手动选过就不覆盖
const detected = autoDetectTag(text);
noteTag = detected;
document.querySelectorAll('.notes-tag-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tag === detected);
});
}
function autoDetectTag(text) {
// 1. 目标(最高优先级)
if (/目标[是::]|这个月.*完成|计划.*完成|准备.*达到/.test(text)) return '目标';
// 2. 周期性提醒
if (/每[一二两三四五六七八九十\d]+个?[月天日周].*提醒/.test(text)) return '提醒';
// 3. 一次性提醒(含"提醒"且有时间词)
if (/提醒/.test(text) && (/\d+分钟|分钟后|分钟以后|小时后|小时以后|\d+天后|天以后|明天|后天|\d+[点:]|半小时/.test(text))) return '提醒';
// 4. Bug
if (/^(?:bug|BUG|Bug)[:\uff1a\s]/i.test(text)) return 'Bug';
// 5. 经期
if (/大姨妈|月经|姨妈来|例假|经期/.test(text)) return '经期';
// 6. 睡眠(排除目标)
if (/现在睡|马上睡|去睡了|去睡觉|睡了$|睡觉了/.test(text) && !/目标/.test(text)) return '睡眠';
if (/昨[天晚].*\d+[点:].*睡|\d+[点:].*睡|入睡|上床/.test(text) && !/目标/.test(text)) return '睡眠';
// 7. 读书
if (/读完|看完|听完|开始读|在读|正在读|这本书|读了|看了|《/.test(text)) return '读书';
// 8. 健身(排除提醒)
if (/健身|深蹲|硬拉|臀推|推胸|卧推|飞鸟|划船|引体|弯举|下拉|推肩/.test(text) && !/提醒/.test(text)) return '健身';
// 9. 音乐
const textClean = text.replace(/\s+/g, '').toLowerCase();
const allMusicNames = (typeof musicItems !== 'undefined' ? musicItems : []).map(i => i.name);
if (allMusicNames.some(n => textClean.includes(n.replace(/\s+/g,'').toLowerCase())) || /练琴|弹了|吹了/.test(text)) return '音乐';
// 10. 健康(匹配物品池,排除提醒)
const allHealthNames = (typeof healthItems !== 'undefined' ? healthItems : []).map(i => i.name);
if (allHealthNames.some(n => textClean.includes(n.replace(/\s+/g,'').toLowerCase())) && !/提醒/.test(text)) return '健康';
// 11. 待办
if (/要|需要|记得|别忘|买|预约|联系|回复|交|还|办|约|寄|取|修|换|查|问|申请|报名|缴/.test(text)) return '待办';
// 12. 默认
return '灵感';
}
function processSingleNote(text, allNotes) {
const now = new Date();
const tag = autoDetectTag(text);
const noteId = Date.now() + Math.floor(Math.random() * 1000);
const timeStr = now.toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' });
const note = {
text,
tag: manualTagPicked ? noteTag : tag,
id: noteId,
time: timeStr,
date: now.toISOString().slice(0,10),
};
allNotes.unshift(note);
// 如果是待办,同时丢进收集箱
if (note.tag === '待办') {
inbox.push({ text, id: noteId, time: timeStr });
saveInbox();
}
// 检测清单匹配
const clMatched = autoAddToChecklist(text);
if (clMatched.length > 0) {
note.routed = note.routed || [];
clMatched.forEach(n => note.routed.push(`📋 ${n}`));
}
// 检测目标关键词
if (/目标[:]\s*|这个月.*完成|计划.*完成|准备.*达到/.test(text)) {
const goalText = text.replace(/目标[:]\s*|这个月|计划|准备|完成|达到/g, '').trim();
if (goalText && goalText.length >= 2) {
goals.push({
id: Date.now(), title: goalText, month: new Date().toISOString().slice(0,7),
subtasks: [], createdAt: dateKey(new Date()), status: 'active',
});
saveGoals();
note.routed = note.routed || [];
note.routed.push('🎯 新目标');
}
}
// 检测健康日记关键词
if (/健康记录|健康日记|身体状况|身体记录|又又.*身体|萝卜.*身体/.test(text)) {
const diaryText = text.replace(/健康记录|健康日记|身体状况|身体记录/g, '').replace(/^[,:\s]+/, '').trim();
if (diaryText) {
healthDiary.unshift({ id: Date.now(), text: diaryText, tag: detectDiaryTag(text), time: timeStr, date: now.toISOString().slice(0,10) });
saveHealthDiary();
note.routed = note.routed || [];
note.routed.push('📝 健康日记');
// tag 由 autoDetectTag 决定,不覆盖
}
}
// 检测"现在睡觉"(排除目标描述)
if (/现在睡|马上睡|去睡了|去睡觉|睡了$|睡觉了/.test(text) && !/目标/.test(text)) {
let h = now.getHours(), m = now.getMinutes();
m = Math.round(m / 10) * 10;
if (m === 60) { m = 0; h++; }
if (h >= 24) h -= 24;
const startStr = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
const recDateStr = dateKey(now);
const existIdx = sleepData.findIndex(r => r.date === recDateStr);
if (existIdx >= 0) sleepData[existIdx].start = startStr;
else sleepData.push({ date: recDateStr, start: startStr });
sleepData.sort((a,b) => b.date.localeCompare(a.date));
saveSleep();
syncSleepToBuddy({ date: recDateStr, start: startStr });
note.routed = note.routed || [];
note.routed.push(`🌙 ${startStr}`);
// tag 由 autoDetectTag 决定
// 即时睡觉的话通知好友("现在睡""去睡了"等,不是"昨天X点睡"
const _bu = getBuddyUsername();
if (_bu) fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user: _bu, action:'sleep-now' })
}).catch(()=>{});
// 联动按钮 UI
const _sbtn = document.getElementById('plannerSleepBtn');
if (_sbtn) { _sbtn.textContent = '🌙 晚安!已通知好友'; _sbtn.style.opacity = '0.6'; setTimeout(() => { _sbtn.textContent = '🌙 我去睡觉啦'; _sbtn.style.opacity = '1'; }, 3000); }
}
// 自动创建 Bug"bug:" "bug" "BUG " 开头)
const bugMatch = text.match(/^(?:bug|BUG|Bug)[:\uff1a\s]\s*(.+)/);
if (bugMatch) {
const bugText = bugMatch[1].trim();
if (bugText) {
bugs.push({ id: Date.now(), text: bugText, status: 'open', priority: 2, log: [{ action: '创建(来自随手记)', time: new Date().toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }) }], createdAt: dateKey(now), time: new Date().toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }) });
saveBugs();
note.routed = note.routed || [];
note.routed.push('🐛 Bug');
// tag 由 autoDetectTag 决定
}
}
// 经期联动:"来大姨妈了""月经来了""姨妈来了""例假来了"
if (/大姨妈来|月经来|姨妈来|例假来|经期开始|来月经|来大姨妈|来例假/.test(text)) {
const dateStr = dateKey(now);
if (!periodData.find(r => r.start === dateStr)) {
periodData.unshift({ start: dateStr, cycle_length: 0, id: Date.now() });
// 计算上一周期长度
if (periodData.length >= 2) {
const d1 = new Date(periodData[0].start), d2 = new Date(periodData[1].start);
periodData[1].cycle_length = Math.round((d1 - d2) / 86400000);
}
savePeriod();
note.routed = note.routed || [];
note.routed.push('🌸 经期');
// tag 由 autoDetectTag 决定
}
}
// 健身联动:"今天练了臀推30kg\n飞鸟4kg" 或 "健身:推胸 2.5kg*2"
if (/健身|练了|训练|深蹲|硬拉|臀推|推胸|卧推|飞鸟|划船|引体|弯举|下拉|推肩/.test(text) && !/提醒/.test(text)) {
const dateStr = dateKey(now);
let rec = gymData.find(r => r.date === dateStr);
if (!rec) { rec = { date: dateStr, items: [], id: Date.now() }; gymData.unshift(rec); }
// 提取每一行的动作和重量
const lines = text.replace(/^(?:今天|健身|训练)[:,\s]*/i, '').split(/[,\n]/).map(s => s.trim()).filter(Boolean);
let added = 0;
lines.forEach(line => {
// 去掉 "练了" 等前缀
line = line.replace(/^练了|^做了/, '').trim();
const m = line.match(/^(.+?)\s*(\d[\d.kgKG*×xX\s\-,,组个]+.*)$/);
if (m) { rec.items.push({ exercise: m[1].trim(), weight: m[2].trim() }); added++; }
else if (/深蹲|硬拉|臀推|推胸|卧推|飞鸟|划船|引体|弯举|下拉|推肩|箭步|面拉/.test(line)) {
rec.items.push({ exercise: line, weight: '' }); added++;
}
});
if (added > 0) {
saveGym();
note.routed = note.routed || [];
note.routed.push(`💪 健身${added}`);
// tag 由 autoDetectTag 决定
}
}
// 解析自定义提醒
const customReminder = parseCustomReminder(text);
if (customReminder) {
const reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
reminders.push(customReminder);
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
note.routed = note.routed || [];
note.routed.push(`${customReminder.timeLabel}提醒`);
}
// 自动路由到个人文档
const routed = autoRouteNote(text, noteId, timeStr);
// 自动健康打卡(如果是提醒类文本,跳过打卡匹配)
const isReminder = !!customReminder || /每[一二两三四五六七八九十\d]+个?[月天日周].*提醒/.test(text);
const healthMatched = isReminder ? [] : autoCheckHealth(text);
const musicMatched = isReminder ? [] : autoCheckMusic(text);
// 路由标签
const allRouted = routed.map(r => `${r.icon} ${r.name}`);
if (healthMatched.length > 0) allRouted.push('💊 健康打卡');
if (musicMatched.length > 0) allRouted.push('🎵 音乐打卡');
if (allRouted.length > 0) note.routed = (note.routed || []).concat(allRouted);
return { routed, healthMatched, musicMatched };
}
async function saveNoteFromMain() {
try {
const input = document.getElementById('notesCaptureInput');
const rawText = input.value.trim();
if (!rawText) return;
const pieces = rawText.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean);
const allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
const toastNames = [];
for (const text of pieces) {
const result = processSingleNote(text, allNotes);
result.routed.forEach(r => toastNames.push(r.name));
if (result.healthMatched.length > 0) toastNames.push(`健康打卡:${result.healthMatched.join('、')}`);
if (result.musicMatched.length > 0) toastNames.push(`音乐打卡:${result.musicMatched.join('、')}`);
}
localStorage.setItem('sp_notes', JSON.stringify(allNotes));
input.value = '';
input.style.height = 'auto';
manualTagPicked = false;
noteTag = '灵感';
document.querySelectorAll('.notes-tag-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tag === '灵感');
});
renderNotes();
// 手机上再次确保清空(防止 renderNotes 后恢复)
setTimeout(() => {
const el = document.getElementById('notesCaptureInput');
if (el) { el.value = ''; el.style.height = 'auto'; }
}, 50);
if (pieces.length > 1) {
showRouteToast([`已拆分为 ${pieces.length} 条记录`]);
} else if (toastNames.length > 0) {
showRouteToast(toastNames);
}
await pushNow();
} catch(e) {
setSyncStatus('错误:' + e.message?.slice(0,20));
}
}
function filterNotes(tag, btn) {
notesFilter = tag;
document.querySelectorAll('.notes-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const label = document.getElementById('notesFilterLabel');
if (label) label.textContent = tag === 'all' ? '全部' : (TAG_ICONS[tag]||'') + ' ' + tag;
document.getElementById('notesFilterRow').style.display = 'none';
renderNotes();
}
function toggleNotesFilter() {
const row = document.getElementById('notesFilterRow');
row.style.display = row.style.display === 'none' ? 'flex' : 'none';
}
function renderRemindersPanel() {
const panel = document.getElementById('remindersPanel');
if (!panel) return;
const now = new Date();
const todayStr = dateKey(now);
const nowMins = now.getHours() * 60 + now.getMinutes();
// ===== 每日固定提醒 =====
const defaultFixed = [
{ icon:'💆', title:'头疗 + 吃药', time:'09:10', id:'morning' },
{ icon:'📋', title:'每日未完成汇总', time:'18:00', id:'health' },
{ icon:'📋', title:'晚间未完成提醒', time:'20:30', id:'evening' },
{ icon:'🌙', title:'准备睡觉', time:'21:30', id:'sleep' },
];
// 从 localStorage 读取用户修改过的固定提醒
const savedFixed = JSON.parse(localStorage.getItem('sp_fixed_reminders') || 'null');
const fixedReminders = defaultFixed.map(d => {
const saved = savedFixed?.find(s => s.id === d.id);
const r = saved ? { ...d, ...saved } : d;
const [h, m] = r.time.split(':').map(Number);
r.mins = h * 60 + m;
r.desc = `每天 ${r.time}`;
return r;
});
let todayItems = [];
fixedReminders.forEach(r => {
const dismissed = localStorage.getItem('sp_dismissed_' + r.id) === todayStr;
const pastMins = nowMins - r.mins;
let status, statusCls, isDone;
if (dismissed) { status = '已确认'; statusCls = 'done'; isDone = true; }
else if (pastMins >= 0 && pastMins < 60) { status = '进行中'; statusCls = 'pending'; isDone = false; }
else if (pastMins >= 60) { status = '已过'; statusCls = 'done'; isDone = true; }
else { status = r.time; statusCls = 'upcoming'; isDone = false; }
todayItems.push({ ...r, status, statusCls, done: isDone, type:'fixed' });
});
// ===== 今天的自定义提醒 =====
const allCustom = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
allCustom.filter(r => r.date === todayStr).forEach(r => {
const targetMins = r.hour * 60 + r.minute;
let status, statusCls;
if (r.dismissed) { status = '已确认'; statusCls = 'done'; }
else if (nowMins >= targetMins) { status = '进行中'; statusCls = 'pending'; }
else { status = r.time; statusCls = 'upcoming'; }
todayItems.push({
icon:'⏰', title: r.content, desc:`今天 ${r.time}`, time: r.time,
mins: targetMins, id: r.id,
status, statusCls, done: r.dismissed, type:'custom',
});
});
todayItems.sort((a,b) => a.mins - b.mins);
// ===== 未来的自定义提醒 =====
const futures = allCustom
.filter(r => r.date > todayStr && !r.dismissed)
.sort((a,b) => a.date.localeCompare(b.date) || a.time.localeCompare(b.time));
// ===== 过去的自定义提醒最近7天已完成 =====
const pastDone = allCustom
.filter(r => r.date < todayStr && r.dismissed)
.sort((a,b) => b.date.localeCompare(a.date))
.slice(0, 5);
// ===== 今日概览 =====
let html = '';
const overviewItems = [];
// 待办:前三个象限未完成
['q1','q2','q3'].forEach(qk => {
const label = {q1:'❗',q2:'⭐',q3:'⏳'}[qk];
(todos[qk]||[]).filter(t=>!t.done).forEach(t => {
const overdue = t.deadline && t.deadline < todayStr;
overviewItems.push({ text: t.text, tag: label, overdue });
});
});
// 健康未打卡
const uhHealth = getUncheckedHealthItems();
uhHealth.forEach(i => overviewItems.push({ text: i.name, tag: '💊' }));
// 音乐未打卡
const mPlan = musicPlans[getMonthKey(new Date())] || [];
const mChk = musicChecks[todayStr] || {};
mPlan.map(id => musicItems.find(i => i.id === id)).filter(i => i && !mChk[i.id])
.forEach(i => overviewItems.push({ text: i.name, tag: '🎵' }));
// 目标未完成
const uncheckedGoals = getUncheckedGoalTasks();
uncheckedGoals.slice(0,3).forEach(t => overviewItems.push({ text: t.text.slice(0,20), tag: '🎯' }));
if (uncheckedGoals.length > 3) overviewItems.push({ text: `…还有${uncheckedGoals.length-3}项目标`, tag: '🎯' });
// 收集箱
if (inbox.length > 0) overviewItems.push({ text: `${inbox.length} 条待分类`, tag: '📥' });
if (overviewItems.length > 0) {
let itemsHtml = '';
overviewItems.forEach(item => {
const style = item.overdue ? 'color:#ef4444;' : 'color:#555;';
itemsHtml += `<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:13px;">
<span style="flex-shrink:0;">${item.tag}</span>
<span style="${style}">${escHtml(item.text.slice(0,30))}${item.overdue?' ⚠️':''}</span>
</div>`;
});
html += `<div style="background:white;border-radius:12px;padding:14px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<div onclick="document.getElementById('overviewList').style.display=document.getElementById('overviewList').style.display==='none'?'block':'none';this.querySelector('.ov-arrow').textContent=document.getElementById('overviewList').style.display==='none'?'':'⌄'" style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;">
<span style="font-size:14px;font-weight:600;color:#444;">📌 今日未完成 <span style="font-size:12px;color:#aaa;font-weight:400;">${overviewItems.length}项</span></span>
<span class="ov-arrow" style="color:#ccc;font-size:14px;"></span>
</div>
<div id="overviewList" style="display:none;margin-top:8px;">${itemsHtml}</div>
</div>`;
}
// 今天
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="font-size:16px;">今日提醒</h3>
<span style="font-size:13px;color:#888;">${formatDate(now)}</span>
</div>`;
if (todayItems.length === 0) {
html += '<div class="rp-card"><div class="rp-icon">✨</div><div class="rp-info"><div class="rp-title" style="color:#aaa;">今天没有提醒</div></div></div>';
} else {
todayItems.forEach(r => {
let actionBtns = '';
if (r.type === 'custom') {
actionBtns = `<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="rp-action-btn" onclick="editCustomReminder('${r.id}')" title="编辑">✎</button>
<button class="rp-action-btn rp-del" onclick="deleteCustomReminder('${r.id}')" title="删除">×</button>
</div>`;
} else if (r.type === 'fixed') {
actionBtns = `<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="rp-action-btn" onclick="editFixedReminder('${r.id}')" title="编辑">✎</button>
</div>`;
}
html += `<div class="rp-card ${r.done?'rp-done':''}">
<div class="rp-icon">${r.icon}</div>
<div class="rp-info">
<div class="rp-title">${escHtml(r.title)}</div>
<div class="rp-desc">${r.desc || ''}</div>
</div>
<span class="rp-status ${r.statusCls}">${r.status}</span>
${actionBtns}
</div>`;
});
}
// 即将到来
if (futures.length > 0) {
html += `<h3 style="font-size:14px;color:#888;margin:20px 0 10px;">即将到来</h3>`;
futures.forEach(r => {
const days = ['周日','周一','周二','周三','周四','周五','周六'];
const d = new Date(r.date);
const dayName = days[d.getDay()];
html += `<div class="rp-card">
<div class="rp-icon">⏰</div>
<div class="rp-info">
<div class="rp-title">${escHtml(r.content)}</div>
<div class="rp-time">${r.date.slice(5)} ${dayName} ${r.time}</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="rp-action-btn" onclick="editCustomReminder('${r.id}')" title="编辑">✎</button>
<button class="rp-action-btn rp-del" onclick="deleteCustomReminder('${r.id}')" title="删除">×</button>
</div>
</div>`;
});
}
// 已完成
if (pastDone.length > 0) {
html += `<h3 style="font-size:14px;color:#ccc;margin:20px 0 10px;">已完成</h3>`;
pastDone.forEach(r => {
html += `<div class="rp-card rp-done">
<div class="rp-icon">✓</div>
<div class="rp-info">
<div class="rp-title">${escHtml(r.content)}</div>
<div class="rp-time">${r.date.slice(5)} ${r.time}</div>
</div>
</div>`;
});
}
panel.innerHTML = html;
}
function editFixedReminder(id) {
const saved = JSON.parse(localStorage.getItem('sp_fixed_reminders') || '[]');
const existing = saved.find(r => r.id === id);
const defaults = { morning: { title:'头疗 + 吃药', time:'09:10' }, health: { title:'健康打卡检查', time:'18:00' }, sleep: { title:'准备睡觉', time:'21:30' } };
const current = existing || defaults[id] || {};
// 直接在卡片下方展开编辑表单
const card = document.querySelector(`[data-edit-id="${id}"]`);
if (card) { card.remove(); renderRemindersPanel(); return; } // 点第二次关闭
const container = document.getElementById('fixedEdit_' + id);
if (container) return;
// 找到对应卡片,插入编辑区
const cards = document.querySelectorAll('.rp-card');
let targetCard = null;
cards.forEach(c => { if (c.querySelector(`[onclick*="editFixedReminder('${id}')"]`)) targetCard = c; });
if (!targetCard) return;
const editDiv = document.createElement('div');
editDiv.id = 'fixedEdit_' + id;
editDiv.setAttribute('data-edit-id', id);
editDiv.className = 'rp-edit-form';
editDiv.innerHTML = `
<div class="rp-edit-row"><label>名称</label><input type="text" id="fe_title_${id}" value="${escHtml(current.title)}"></div>
<div class="rp-edit-row"><label>时间</label><input type="time" id="fe_time_${id}" value="${current.time}"></div>
<div class="rp-edit-row" style="justify-content:flex-end;gap:8px;">
<button class="btn btn-close" onclick="document.getElementById('fixedEdit_${id}').remove()">取消</button>
<button class="btn btn-accent" onclick="saveFixedEdit('${id}')">保存</button>
</div>`;
targetCard.after(editDiv);
}
function saveFixedEdit(id) {
const title = document.getElementById('fe_title_' + id)?.value.trim();
const time = document.getElementById('fe_time_' + id)?.value.trim();
if (!title || !time || !/^\d{1,2}:\d{2}$/.test(time)) return;
const saved = JSON.parse(localStorage.getItem('sp_fixed_reminders') || '[]');
const entry = { id, title, time };
const idx = saved.findIndex(r => r.id === id);
if (idx >= 0) saved[idx] = entry; else saved.push(entry);
localStorage.setItem('sp_fixed_reminders', JSON.stringify(saved));
const rem = REMINDERS.find(r => r.id === id);
if (rem) { const [h,m] = time.split(':').map(Number); rem.hour = h; rem.minute = m; }
renderRemindersPanel(); pushNow();
}
function editCustomReminder(id) {
const card = document.querySelector(`[data-cedit-id="${id}"]`);
if (card) { card.remove(); return; }
let reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
const r = reminders.find(x => x.id === id);
if (!r) return;
const cards = document.querySelectorAll('.rp-card');
let targetCard = null;
cards.forEach(c => { if (c.querySelector(`[onclick*="editCustomReminder('${id}')"]`)) targetCard = c; });
if (!targetCard) return;
const editDiv = document.createElement('div');
editDiv.id = 'customEdit_' + id;
editDiv.setAttribute('data-cedit-id', id);
editDiv.className = 'rp-edit-form';
editDiv.innerHTML = `
<div class="rp-edit-row"><label>内容</label><input type="text" id="ce_content_${id}" value="${escHtml(r.content)}"></div>
<div class="rp-edit-row"><label>时间</label><input type="time" id="ce_time_${id}" value="${r.time}"></div>
<div class="rp-edit-row"><label>日期</label><input type="date" id="ce_date_${id}" value="${r.date}"></div>
<div class="rp-edit-row" style="justify-content:flex-end;gap:8px;">
<button class="btn btn-close" onclick="document.getElementById('customEdit_${id}').remove()">取消</button>
<button class="btn btn-accent" onclick="saveCustomEdit('${id}')">保存</button>
</div>`;
targetCard.after(editDiv);
}
function saveCustomEdit(id) {
let reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
const r = reminders.find(x => x.id === id);
if (!r) return;
const content = document.getElementById('ce_content_' + id)?.value.trim();
const time = document.getElementById('ce_time_' + id)?.value.trim();
const date = document.getElementById('ce_date_' + id)?.value.trim();
if (content) r.content = content;
if (time && /^\d{1,2}:\d{2}$/.test(time)) { const [h,m]=time.split(':').map(Number); r.time=`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; r.hour=h; r.minute=m; }
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) r.date = date;
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
renderRemindersPanel(); pushNow();
}
async function deleteCustomReminder(id) {
if (!await showDialog('确定删除这条提醒?','confirm')) return;
let reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
reminders = reminders.filter(r => r.id !== id);
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
renderRemindersPanel(); pushNow();
}
function renderNotes() {
const allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
const search = (document.getElementById('notesSearch')?.value || '').trim().toLowerCase();
const emptyEl = document.getElementById('notesEmpty');
const listEl = document.getElementById('notesList');
let filtered = allNotes;
if (notesFilter !== 'all') {
filtered = filtered.filter(n => n.tag === notesFilter);
}
if (search) {
filtered = filtered.filter(n => n.text.toLowerCase().includes(search));
}
if (filtered.length === 0) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
emptyEl.textContent = allNotes.length === 0
? '还没有记录,在上方输入框快速记录吧'
: '没有找到匹配的记录';
return;
}
emptyEl.style.display = 'none';
// 未处理的在前,已处理的在后
const pending = filtered.filter(n => !n.done);
const done = filtered.filter(n => n.done);
let html = '';
if (pending.length > 0) {
html += pending.map(n => renderNoteCard(n, false)).join('');
}
if (done.length > 0) {
html += `<div class="nc-done-divider"><span>已处理 (${done.length})</span><button class="nc-clear-done" onclick="clearDoneNotes()">清除已处理</button></div>`;
html += done.map(n => renderNoteCard(n, true)).join('');
}
listEl.innerHTML = html;
}
function renderNoteCard(n, isDone) {
// 路由标签:优先用保存的,兜底动态查
let routedTo = n.routed || [];
if (routedTo.length === 0) {
routedTo = personalDocs
.filter(d => d.entries.some(e => e.noteId === n.id))
.map(d => `${d.icon} ${d.name}`);
}
const routedBadge = routedTo.length > 0
? `<div class="doc-routed-badge">→ ${routedTo.join('、')}</div>`
: '';
return `
<div class="note-card tag-${n.tag} ${isDone ? 'nc-done' : ''}">
<div class="nc-top">
<span class="nc-tag">${TAG_ICONS[n.tag]||'📝'} ${n.tag}</span>
<span class="nc-time">${n.time || ''}</span>
</div>
<div class="nc-text">${escHtml(n.text)}</div>
${routedBadge}
<div class="nc-actions-bottom">
<button class="nc-done-btn ${isDone ? 'is-done' : ''}" onclick="toggleNoteDone(${n.id})">
${isDone ? '↩ 恢复' : '✓ 已处理'}
</button>
<button class="nc-todo-btn" onclick="noteToInbox(${n.id})">→ 待办</button>
<button class="nc-del-btn" onclick="deleteNote(${n.id})">删除</button>
</div>
</div>`;
}
function showRouteToast(docNames) {
// 创建临时 toast
let toast = document.getElementById('routeToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'routeToast';
toast.style.cssText = 'position:fixed;bottom:30px;left:50%;transform:translateX(-50%) translateY(20px);background:#333;color:white;padding:12px 24px;border-radius:12px;font-size:13px;opacity:0;transition:all 0.3s;z-index:999;pointer-events:none;';
document.body.appendChild(toast);
}
toast.textContent = `✨ 已自动归档到:${docNames.join('、')}`;
toast.style.opacity = '1';
toast.style.transform = 'translateX(-50%) translateY(0)';
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(-50%) translateY(20px)';
}, 2500);
}
async function noteToInbox(id) {
const allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
const note = allNotes.find(n => n.id === id);
if (!note) return;
// 去重添加,但不管是否重复都标已处理
if (!inbox.some(i => i.text === note.text)) {
inbox.push({ text: note.text, id: Date.now(), time: note.time || '' });
saveInbox();
}
note.routed = note.routed || [];
if (!note.routed.includes('✅ 待办')) note.routed.push('✅ 待办');
note.done = true;
localStorage.setItem('sp_notes', JSON.stringify(allNotes));
renderNotes();
showRouteToast(['已添加到待办收集箱']);
await pushNow();
}
async function toggleNoteDone(id) {
const allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
const note = allNotes.find(n => n.id === id);
if (note) {
note.done = !note.done;
localStorage.setItem('sp_notes', JSON.stringify(allNotes));
renderNotes();
await pushNow();
}
}
async function clearDoneNotes() {
if (!await showDialog('确定清除所有已处理的记录?','confirm')) return;
let allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
const doneIds = allNotes.filter(n => n.done).map(n => n.id);
allNotes = allNotes.filter(n => !n.done);
_origSetItem('sp_notes', JSON.stringify(allNotes));
// 标记删除
const deleted = JSON.parse(localStorage.getItem('sp_notes_deleted') || '[]');
doneIds.forEach(id => { if (!deleted.includes(id)) deleted.push(id); });
localStorage.setItem('sp_notes_deleted', JSON.stringify(deleted));
renderNotes();
await pushNow();
}
async function deleteNote(id) {
if (!await showDialog('确定删除这条记录?','confirm')) return;
let allNotes = JSON.parse(localStorage.getItem('sp_notes')) || [];
allNotes = allNotes.filter(n => n.id !== id);
_origSetItem('sp_notes', JSON.stringify(allNotes));
// 标记删除,防止其他设备合并回来
const deleted = JSON.parse(localStorage.getItem('sp_notes_deleted') || '[]');
if (!deleted.includes(id)) deleted.push(id);
localStorage.setItem('sp_notes_deleted', JSON.stringify(deleted));
renderNotes();
await pushNow();
}
// ============================================================
// 6. 健康管理
// ============================================================
const DEFAULT_HEALTH_ITEMS = [
{ id:'h_iron', name:'铁', emoji:'💊' },
{ id:'h_vitd', name:'维生素D', emoji:'☀️' },
{ id:'h_omega3', name:'鱼油', emoji:'🐟' },
{ id:'h_probiotic', name:'益生菌', emoji:'🦠' },
{ id:'h_calcium', name:'钙', emoji:'🦴' },
{ id:'h_vitc', name:'维生素C', emoji:'🍊' },
{ id:'h_magnesium', name:'镁', emoji:'✨' },
{ id:'h_collagen', name:'胶原蛋白', emoji:'💎' },
{ id:'h_headtherapy', name:'头疗', emoji:'💆' },
{ id:'h_oil', name:'精油调理', emoji:'🌿' },
{ id:'h_exercise', name:'运动', emoji:'🏃' },
{ id:'h_water', name:'喝够水', emoji:'💧' },
];
let healthItems = JSON.parse(localStorage.getItem('sp_health_items')) || DEFAULT_HEALTH_ITEMS;
let healthPlans = JSON.parse(localStorage.getItem('sp_health_plans')) || {}; // { 'YYYY-MM': ['h_iron', ...] }
let healthChecks = JSON.parse(localStorage.getItem('sp_health_checks')) || {}; // { 'YYYY-MM-DD': { 'h_iron': true } }
let healthViewMonth = new Date();
function saveHealth() {
localStorage.setItem('sp_health_items', JSON.stringify(healthItems));
localStorage.setItem('sp_health_plans', JSON.stringify(healthPlans));
localStorage.setItem('sp_health_checks', JSON.stringify(healthChecks));
markLocalDirty();
}
function getMonthKey(d) { return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}`; }
function getCurrentPlan() { return healthPlans[getMonthKey(new Date())] || []; }
function getTodayChecks() { return healthChecks[dateKey(new Date())] || {}; }
function toggleHealthPlan(itemId) {
if (longPressTriggered) return;
const mk = getMonthKey(healthViewMonth);
if (!healthPlans[mk]) healthPlans[mk] = [];
const idx = healthPlans[mk].indexOf(itemId);
if (idx >= 0) healthPlans[mk].splice(idx, 1);
else healthPlans[mk].push(itemId);
saveHealth(); renderHealth(); pushNow();
}
function toggleHealthCheck(itemId) {
const dk = dateKey(new Date());
if (!healthChecks[dk]) healthChecks[dk] = {};
healthChecks[dk][itemId] = !healthChecks[dk][itemId];
saveHealth(); renderHealth(); pushNow();
}
function addHealthItem() {
const input = document.getElementById('healthNewItem');
const name = input.value.trim();
if (!name) return;
const emojis = ['💊','🌿','💪','🧘','🍵','🫁','🧴','❤️','🥗','🧠'];
healthItems.push({ id: 'h_' + Date.now(), name, emoji: emojis[healthItems.length % emojis.length] });
saveHealth(); renderHealth(); pushNow();
document.getElementById('healthNewItem').value = '';
}
// 长按显示操作菜单(手机端)
let longPressTimer = null;
let longPressTriggered = false;
function startLongPress(e, type, id) {
longPressTriggered = false;
longPressTimer = setTimeout(() => {
longPressTriggered = true;
showPoolActions(type, id);
}, 500);
}
function cancelLongPress() {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
}
function showPoolActions(type, id) {
const items = type === 'health' ? healthItems : musicItems;
const item = items.find(i => i.id === id);
if (!item) return;
// 移除旧菜单
document.getElementById('poolActionSheet')?.remove();
const sheet = document.createElement('div');
sheet.id = 'poolActionSheet';
sheet.className = 'pool-item-actions-mobile open';
sheet.innerHTML = `
<div style="text-align:center;font-size:13px;color:#999;margin-bottom:12px;">${item.emoji} ${item.name}</div>
<button onclick="document.getElementById('poolActionSheet').remove();editPoolItem('${type}','${id}')">编辑名称和图标</button>
<button class="pam-danger" onclick="document.getElementById('poolActionSheet').remove();${type==='health'?'deleteHealthItem':'deleteMusicItem'}('${id}')">删除</button>
<button class="pam-cancel" onclick="document.getElementById('poolActionSheet').remove()">取消</button>
`;
document.body.appendChild(sheet);
// 添加遮罩
const mask = document.createElement('div');
mask.id = 'poolActionMask';
mask.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.15);';
mask.onclick = () => { sheet.remove(); mask.remove(); };
document.body.appendChild(mask);
}
// 阻止长按后的 click 触发 togglePlan
document.addEventListener('click', (e) => {
if (longPressTriggered) { e.stopPropagation(); e.preventDefault(); longPressTriggered = false; }
}, true);
async function editPoolItem(type, id) {
const items = type === 'health' ? healthItems : musicItems;
const item = items.find(i => i.id === id);
if (!item) return;
const newName = await showDialog('修改名称:', 'prompt', item.name);
if (newName === null) return;
if (newName.trim()) item.name = newName.trim();
const newEmoji = await showDialog('修改图标输入一个emoji', 'prompt', item.emoji);
if (newEmoji !== null && newEmoji.trim()) item.emoji = newEmoji.trim();
if (type === 'health') { saveHealth(); renderHealth(); }
else { saveMusic(); renderMusic(); }
pushNow();
}
async function deleteHealthItem(id) {
const item = healthItems.find(i => i.id === id);
if (!await showDialog(`确定删除「${item ? item.name : ''}」?相关的打卡记录也会一起删除`,'confirm')) return;
healthItems = healthItems.filter(i => i.id !== id);
for (const mk in healthPlans) {
healthPlans[mk] = healthPlans[mk].filter(x => x !== id);
}
saveHealth(); renderHealth(); pushNow();
}
function changeHealthMonth(delta) {
healthViewMonth.setMonth(healthViewMonth.getMonth() + delta);
renderHealth();
}
// 从随手记自动打卡
function autoCheckHealth(text) {
const plan = getCurrentPlan();
const dk = dateKey(new Date());
if (!healthChecks[dk]) healthChecks[dk] = {};
let matched = [];
// 去掉空格和转小写,用于模糊匹配
const textClean = text.replace(/\s+/g, '').toLowerCase();
for (const itemId of plan) {
const item = healthItems.find(i => i.id === itemId);
if (!item) continue;
const nameClean = item.name.replace(/\s+/g, '').toLowerCase();
if (textClean.includes(nameClean) && !healthChecks[dk][itemId]) {
healthChecks[dk][itemId] = true;
matched.push(item.name);
}
}
if (matched.length > 0) saveHealth();
return matched;
}
// 获取未完成项目(给提醒系统用)
function getUncheckedHealthItems() {
const plan = getCurrentPlan();
const checks = getTodayChecks();
return plan
.map(id => healthItems.find(i => i.id === id))
.filter(item => item && !checks[item.id]);
}
let healthDiaryOpen = false;
let healthDiary = JSON.parse(localStorage.getItem('sp_health_diary')) || [];
function saveHealthDiary() { localStorage.setItem('sp_health_diary', JSON.stringify(healthDiary)); markLocalDirty(); }
function toggleHealthDiary() {
healthDiaryOpen = !healthDiaryOpen;
document.getElementById('healthDiaryBody').style.display = healthDiaryOpen ? 'block' : 'none';
document.getElementById('diaryArrow').classList.toggle('open', healthDiaryOpen);
if (healthDiaryOpen) renderHealthDiary();
}
const DIARY_TAGS = [
{ name:'又又', keywords:['又又','youyou'], color:'#e3f2fd', fg:'#1565c0' },
{ name:'萝卜', keywords:['萝卜','luobo'], color:'#fce4ec', fg:'#c62828' },
{ name:'我', keywords:[], color:'#e8f5e9', fg:'#2e7d32' },
];
function detectDiaryTag(text) {
// 去空格匹配,防止手机输入法加空格
const clean = text.replace(/\s+/g, '');
for (const t of DIARY_TAGS) {
if (t.name !== '我' && clean.includes(t.name)) return t.name;
if (t.keywords.some(kw => clean.toLowerCase().includes(kw.toLowerCase()))) return t.name;
}
return '我';
}
function addHealthDiary() {
const input = document.getElementById('healthDiaryInput');
const text = input.value.trim();
if (!text) return;
const now = new Date();
healthDiary.unshift({
id: Date.now(),
text,
tag: detectDiaryTag(text),
time: now.toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }),
date: now.toISOString().slice(0,10),
});
input.value = '';
input.style.height = 'auto';
saveHealthDiary();
renderHealthDiary(); pushNow();
}
async function deleteHealthDiary(id) {
if (!await showDialog('确定删除这条日记?','confirm')) return;
healthDiary = healthDiary.filter(d => d.id !== id);
_origSetItem('sp_health_diary', JSON.stringify(healthDiary));
renderHealthDiary();
await pushNow();
}
let diaryFilterTag = 'all';
function renderHealthDiary() {
const list = document.getElementById('healthDiaryList');
if (!list) return;
if (healthDiary.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#ccc;font-size:13px;padding:20px;">还没有日记</div>';
return;
}
// 标签筛选按钮
let html = '<div style="display:flex;gap:4px;margin-bottom:8px;">';
html += `<button style="padding:3px 8px;border-radius:6px;border:1px solid #ddd;background:${diaryFilterTag==='all'?'#667eea':'white'};color:${diaryFilterTag==='all'?'white':'#888'};font-size:11px;cursor:pointer;" onclick="diaryFilterTag='all';renderHealthDiary()">全部</button>`;
DIARY_TAGS.forEach(t => {
const active = diaryFilterTag === t.name;
html += `<button style="padding:3px 8px;border-radius:6px;border:1px solid ${active?t.fg:'#ddd'};background:${active?t.color:'white'};color:${active?t.fg:'#888'};font-size:11px;cursor:pointer;" onclick="diaryFilterTag='${t.name}';renderHealthDiary()">${t.name}</button>`;
});
html += '</div>';
const filtered = diaryFilterTag === 'all' ? healthDiary : healthDiary.filter(d => (d.tag || '我') === diaryFilterTag);
html += filtered.slice(0, 30).map(d => {
const tag = DIARY_TAGS.find(t => t.name === (d.tag || '我')) || DIARY_TAGS[2];
return `<div class="health-diary-item">
<div class="hd-date">${d.time || d.date}</div>
<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:${tag.color};color:${tag.fg};flex-shrink:0;">${tag.name}</span>
<div class="hd-text">${escHtml(d.text)}</div>
<button class="hd-del" onclick="deleteHealthDiary(${d.id})">×</button>
</div>`;
}).join('');
list.innerHTML = html;
}
function renderHealth() {
const now = new Date();
const todayStr = dateKey(now);
const mk = getMonthKey(now);
const viewMk = getMonthKey(healthViewMonth);
const plan = healthPlans[mk] || [];
const checks = healthChecks[todayStr] || {};
// 日期
document.getElementById('healthDate').textContent = formatDate(now);
// 今日打卡
const todayGrid = document.getElementById('healthTodayGrid');
const todayEmpty = document.getElementById('healthTodayEmpty');
if (plan.length === 0) {
todayGrid.innerHTML = '';
todayEmpty.style.display = 'block';
} else {
todayEmpty.style.display = 'none';
todayGrid.innerHTML = plan.map(id => {
const item = healthItems.find(i => i.id === id);
if (!item) return '';
const done = checks[id];
return `<div class="health-today-item ${done?'done':''}" onclick="toggleHealthCheck('${id}')">
<span>${item.emoji} ${item.name}</span>
</div>`;
}).join('');
}
// 月份标签
const vy = healthViewMonth.getFullYear();
const vm = healthViewMonth.getMonth();
document.getElementById('healthMonthLabel').textContent = healthYearView ? `${vy}` : `${vy}${vm+1}`;
// 物品池
const viewPlan = healthPlans[viewMk] || [];
document.getElementById('healthPool').innerHTML = healthItems.map(item => {
const inPlan = viewPlan.includes(item.id);
return `<div class="health-pool-item ${inPlan?'in-plan':''}"
onclick="toggleHealthPlan('${item.id}')"
oncontextmenu="event.preventDefault();showPoolActions('health','${item.id}')"
ontouchstart="startLongPress(event,'health','${item.id}')" ontouchend="cancelLongPress()" ontouchmove="cancelLongPress()">
${item.emoji} ${item.name}
<button class="hp-edit" onclick="event.stopPropagation();editPoolItem('health','${item.id}')" title="编辑">✎</button>
<button class="hp-del" onclick="event.stopPropagation();deleteHealthItem('${item.id}')">×</button>
</div>`;
}).join('');
// 月历 / 年度视图
if (healthYearView) {
renderYearView('healthCalendar', healthPlans, healthChecks, healthItems, vy);
} else {
renderMonthCalendar('healthCalendar', viewPlan, healthChecks, healthItems, vy, vm, 'health');
}
}
// ============================================================
// 6b. 音乐打卡
// ============================================================
const DEFAULT_MUSIC_ITEMS = [
{ id:'m_guitar', name:'吉他', emoji:'🎸' },
{ id:'m_trumpet', name:'吹号', emoji:'🎺' },
{ id:'m_piano', name:'钢琴', emoji:'🎹' },
{ id:'m_ukulele', name:'尤克里里', emoji:'🪕' },
{ id:'m_singing', name:'唱歌', emoji:'🎤' },
{ id:'m_theory', name:'乐理', emoji:'🎼' },
];
let musicItems = JSON.parse(localStorage.getItem('sp_music_items')) || DEFAULT_MUSIC_ITEMS;
let musicPlans = JSON.parse(localStorage.getItem('sp_music_plans')) || {};
let musicChecks = JSON.parse(localStorage.getItem('sp_music_checks')) || {};
let musicViewMonth = new Date();
let musicYearView = false;
function saveMusic() {
localStorage.setItem('sp_music_items', JSON.stringify(musicItems));
localStorage.setItem('sp_music_plans', JSON.stringify(musicPlans));
localStorage.setItem('sp_music_checks', JSON.stringify(musicChecks));
markLocalDirty();
}
function toggleMusicPlan(id) {
if (longPressTriggered) return;
const mk = getMonthKey(musicViewMonth);
if (!musicPlans[mk]) musicPlans[mk] = [];
const idx = musicPlans[mk].indexOf(id);
if (idx >= 0) musicPlans[mk].splice(idx, 1);
else musicPlans[mk].push(id);
saveMusic(); renderMusic(); pushNow();
}
function toggleMusicCheck(id) {
const dk = dateKey(new Date());
if (!musicChecks[dk]) musicChecks[dk] = {};
musicChecks[dk][id] = !musicChecks[dk][id];
saveMusic(); renderMusic(); pushNow();
}
async function deleteMusicItem(id) {
const item = musicItems.find(i => i.id === id);
if (!await showDialog(`确定删除「${item ? item.name : ''}」?`,'confirm')) return;
musicItems = musicItems.filter(i => i.id !== id);
saveMusic(); renderMusic(); pushNow();
}
function addMusicItem() {
const input = document.getElementById('musicNewItem');
const name = input.value.trim();
if (!name) return;
const emojis = ['🎵','🎶','🥁','🎷','🎻','🪗'];
musicItems.push({ id:'m_'+Date.now(), name, emoji: emojis[musicItems.length % emojis.length] });
saveMusic(); renderMusic(); pushNow();
document.getElementById('musicNewItem').value = '';
}
function changeMusicMonth(d) { musicViewMonth.setMonth(musicViewMonth.getMonth()+d); renderMusic(); }
function toggleMusicView() {
musicYearView = !musicYearView;
document.getElementById('musicViewBtn').classList.toggle('active', musicYearView);
document.getElementById('musicViewBtn').textContent = musicYearView ? '月度' : '年度';
renderMusic();
}
function autoCheckMusic(text) {
const plan = musicPlans[getMonthKey(new Date())] || [];
const dk = dateKey(new Date());
if (!musicChecks[dk]) musicChecks[dk] = {};
let matched = [];
for (const id of plan) {
const item = musicItems.find(i => i.id === id);
if (item && text.includes(item.name) && !musicChecks[dk][id]) {
musicChecks[dk][id] = true;
matched.push(item.name);
}
}
if (matched.length > 0) saveMusic();
return matched;
}
function renderMusic() {
const now = new Date();
const mk = getMonthKey(now);
const viewMk = getMonthKey(musicViewMonth);
const plan = musicPlans[mk] || [];
const checks = musicChecks[dateKey(now)] || {};
document.getElementById('musicDate').textContent = formatDate(now);
const grid = document.getElementById('musicTodayGrid');
const empty = document.getElementById('musicTodayEmpty');
if (plan.length === 0) { grid.innerHTML = ''; empty.style.display = 'block'; }
else {
empty.style.display = 'none';
grid.innerHTML = plan.map(id => {
const item = musicItems.find(i => i.id === id);
if (!item) return '';
const done = checks[id];
return `<div class="health-today-item ${done?'done':''}" onclick="toggleMusicCheck('${id}')">
<span>${item.emoji} ${item.name}</span>
</div>`;
}).join('');
}
const vy = musicViewMonth.getFullYear(), vm = musicViewMonth.getMonth();
document.getElementById('musicMonthLabel').textContent = musicYearView ? `${vy}` : `${vy}${vm+1}`;
const viewPlan = musicPlans[viewMk] || [];
document.getElementById('musicPool').innerHTML = musicItems.map(item => {
const inPlan = viewPlan.includes(item.id);
return `<div class="health-pool-item ${inPlan?'in-plan':''}"
onclick="toggleMusicPlan('${item.id}')"
oncontextmenu="event.preventDefault();showPoolActions('music','${item.id}')"
ontouchstart="startLongPress(event,'music','${item.id}')" ontouchend="cancelLongPress()" ontouchmove="cancelLongPress()">
${item.emoji} ${item.name}
<button class="hp-edit" onclick="event.stopPropagation();editPoolItem('music','${item.id}')" title="编辑">✎</button>
<button class="hp-del" onclick="event.stopPropagation();deleteMusicItem('${item.id}')">×</button>
</div>`;
}).join('');
if (musicYearView) {
renderYearView('musicCalendar', musicPlans, musicChecks, musicItems, vy);
} else {
renderMonthCalendar('musicCalendar', viewPlan, musicChecks, musicItems, vy, vm, 'music');
}
}
// ============================================================
// 共享:月历 + 年度视图
// ============================================================
function renderMonthCalendar(containerId, plan, checks, items, year, month, type) {
const container = document.getElementById(containerId);
if (plan.length === 0) {
container.innerHTML = '<div class="health-empty">点击上方物品池中的项目添加到本月计划</div>';
return;
}
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayDay = (today.getFullYear() === year && today.getMonth() === month) ? today.getDate() : -1;
let html = '<table class="health-cal-table"><thead><tr><th></th>';
for (let d = 1; d <= daysInMonth; d++) html += `<th>${d}</th>`;
html += '</tr></thead><tbody>';
for (const itemId of plan) {
const item = items.find(i => i.id === itemId);
if (!item) continue;
html += `<tr><td style="text-align:left;padding-right:8px;white-space:nowrap;font-size:12px;">${item.emoji} ${item.name}</td>`;
for (let d = 1; d <= daysInMonth; d++) {
const dk = `${year}-${(month+1).toString().padStart(2,'0')}-${d.toString().padStart(2,'0')}`;
const done = checks[dk]?.[itemId];
const isToday = d === todayDay;
const isFuture = d > todayDay && todayDay > 0;
let cls = isFuture ? 'future' : done ? 'done' : 'missed';
if (isToday) cls += ' today';
html += `<td><span class="health-cal-dot ${cls}" style="cursor:pointer" onclick="toggleCalDot('${type}','${dk}','${itemId}')">${done ? '✓' : isFuture ? '' : '·'}</span></td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
}
function toggleCalDot(type, dk, itemId) {
const checks = type === 'health' ? healthChecks : musicChecks;
if (!checks[dk]) checks[dk] = {};
checks[dk][itemId] = !checks[dk][itemId];
if (type === 'health') { saveHealth(); renderHealth(); }
else { saveMusic(); renderMusic(); }
pushNow();
}
function renderYearView(containerId, plans, checks, items, year) {
const container = document.getElementById(containerId);
const today = new Date();
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
// 收集所有计划项目
const allPlanIds = new Set();
for (let m = 1; m <= 12; m++) {
const mk = `${year}-${m.toString().padStart(2,'0')}`;
(plans[mk] || []).forEach(id => allPlanIds.add(id));
}
if (allPlanIds.size === 0) {
container.innerHTML = '<div class="health-empty">今年还没有打卡计划</div>';
return;
}
let html = '';
// 每个项目一个区块
for (const itemId of allPlanIds) {
const item = items.find(i => i.id === itemId);
if (!item) continue;
// 统计完成天数
let totalDone = 0, totalPlan = 0;
for (let m = 1; m <= 12; m++) {
const mk = `${year}-${m.toString().padStart(2,'0')}`;
if (!(plans[mk] || []).includes(itemId)) continue;
const days = new Date(year, m, 0).getDate();
for (let d = 1; d <= days; d++) {
const dk = `${year}-${mk.slice(5)}-${d.toString().padStart(2,'0')}`;
const isFuture = new Date(year, m-1, d) > today;
if (!isFuture) totalPlan++;
if (checks[dk]?.[itemId]) totalDone++;
}
}
html += `<div style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:16px;">${item.emoji}</span>
<span style="font-size:13px;font-weight:600;color:#444;">${item.name}</span>
<span style="font-size:11px;color:#aaa;margin-left:auto;">${totalDone}/${totalPlan}天</span>
</div>
<div class="year-month-grid">`;
for (let m = 0; m < 12; m++) {
const mk = `${year}-${(m+1).toString().padStart(2,'0')}`;
const inPlan = (plans[mk] || []).includes(itemId);
const daysInMonth = new Date(year, m + 1, 0).getDate();
html += `<div class="year-month-block">
<div class="year-month-label">${months[m]}</div>
<div class="year-month-cells">`;
for (let d = 1; d <= daysInMonth; d++) {
const dk = `${year}-${(m+1).toString().padStart(2,'0')}-${d.toString().padStart(2,'0')}`;
const done = checks[dk]?.[itemId];
const isToday = year === today.getFullYear() && m === today.getMonth() && d === today.getDate();
const isFuture = new Date(year, m, d) > today;
let cls = '';
if (!inPlan) cls = '';
else if (done) cls = 'done';
else if (isFuture) cls = '';
else cls = 'missed';
if (isToday) cls += ' today';
html += `<div class="year-cell ${cls}" title="${m+1}/${d}"></div>`;
}
html += '</div></div>';
}
html += '</div></div>';
}
html += `<div class="year-legend">
<span><span class="year-legend-dot" style="background:#22c55e"></span>已完成</span>
<span><span class="year-legend-dot" style="background:#fecaca"></span>未完成</span>
<span><span class="year-legend-dot" style="background:#f0f0f0"></span>无计划</span>
</div>`;
container.innerHTML = html;
}
// 健康管理的年度视图开关
let healthYearView = false;
function toggleHealthView() {
healthYearView = !healthYearView;
document.getElementById('healthViewBtn').classList.toggle('active', healthYearView);
document.getElementById('healthViewBtn').textContent = healthYearView ? '月度' : '年度';
renderHealth();
}
// ============================================================
// 7a. 清单系统
// ============================================================
let checklists = JSON.parse(localStorage.getItem('sp_checklists')) || [];
function saveChecklists() { localStorage.setItem('sp_checklists', JSON.stringify(checklists)); markLocalDirty(); }
async function addChecklist() {
const name = await showDialog('清单名称', 'prompt', '');
if (!name || !name.trim()) return;
checklists.push({ id: Date.now(), name: name.trim(), items: [], archived: false });
saveChecklists(); renderChecklists(); pushNow();
}
async function deleteChecklist(id) {
if (!await showDialog('确定删除这个清单?','confirm')) return;
checklists = checklists.filter(c => c.id !== id);
saveChecklists(); renderChecklists(); pushNow();
}
function archiveChecklist(id) {
const cl = checklists.find(c => c.id === id);
if (cl) { cl.archived = !cl.archived; saveChecklists(); renderChecklists(); pushNow(); }
}
function uncheckAllChecklist(id) {
const cl = checklists.find(c => c.id === id);
if (cl) { cl.items.forEach(i => i.done = false); saveChecklists(); renderChecklists(); pushNow(); }
}
function toggleChecklistItem(clId, itemIdx) {
const cl = checklists.find(c => c.id === clId);
if (cl && cl.items[itemIdx] !== undefined) {
cl.items[itemIdx].done = !cl.items[itemIdx].done;
saveChecklists(); renderChecklists(); pushNow();
}
}
function addChecklistItem(clId) {
const input = document.getElementById('cl_input_' + clId);
const rawText = input.value.trim();
if (!rawText) return;
const cl = checklists.find(c => c.id === clId);
if (!cl) return;
// 支持多行粘贴:每行一个 item
const lines = rawText.split('\n').map(s => s.replace(/^[\s\-\*\•\d\.]+/, '').trim()).filter(Boolean);
lines.forEach(text => cl.items.push({ text, done: false }));
input.value = '';
saveChecklists(); renderChecklists(); pushNow();
}
async function deleteChecklistItem(clId, idx) {
const cl = checklists.find(c => c.id === clId);
if (cl) { cl.items.splice(idx, 1); saveChecklists(); renderChecklists(); pushNow(); }
}
// 从随手记模糊匹配添加到清单
function autoAddToChecklist(text) {
const matched = [];
checklists.filter(c => !c.archived).forEach(cl => {
// 如果笔记内容包含清单名称,就把笔记内容加为一个 item
if (text.includes(cl.name)) {
const itemText = text.replace(cl.name, '').replace(/^[,:\s加到放到]+/, '').trim();
if (itemText && itemText.length >= 2) {
cl.items.push({ text: itemText, done: false });
matched.push(cl.name);
}
}
});
if (matched.length > 0) saveChecklists();
return matched;
}
let clOpenIds = new Set();
function renderChecklists() {
const search = (document.getElementById('checklistSearch')?.value || '').toLowerCase();
const listEl = document.getElementById('checklistList');
const archiveEl = document.getElementById('checklistArchive');
const active = checklists.filter(c => !c.archived);
const archived = checklists.filter(c => c.archived);
// 活跃清单
if (active.length === 0 && !search) {
listEl.innerHTML = '<div style="color:#ccc;font-size:13px;padding:20px 0;text-align:center;">还没有清单</div>';
} else {
listEl.innerHTML = active.filter(cl => !search || cl.name.toLowerCase().includes(search) || cl.items.some(i => i.text.toLowerCase().includes(search)))
.map(cl => renderChecklistCard(cl, search)).join('');
}
// 存档区
if (archived.length > 0) {
const showArchive = document.getElementById('clArchiveOpen')?.checked;
archiveEl.innerHTML = `
<div style="margin-top:20px;display:flex;align-items:center;gap:8px;cursor:pointer;" onclick="document.getElementById('clArchiveBody').style.display=document.getElementById('clArchiveBody').style.display==='none'?'block':'none'">
<span style="font-size:12px;color:#aaa;">存档 (${archived.length})</span>
<span style="color:#ccc;font-size:12px;"></span>
</div>
<div id="clArchiveBody" style="display:none;">
${archived.map(cl => renderChecklistCard(cl, search)).join('')}
</div>`;
} else {
archiveEl.innerHTML = '';
}
}
function renderChecklistCard(cl, search) {
const isOpen = clOpenIds.has(cl.id);
const doneCount = cl.items.filter(i => i.done).length;
const total = cl.items.length;
let itemsHtml = '';
if (isOpen) {
const filtered = search ? cl.items.map((item,i) => ({...item, _idx:i})).filter(item => item.text.toLowerCase().includes(search)) : cl.items.map((item,i) => ({...item, _idx:i}));
itemsHtml = filtered.map(item => `
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid #f8f8f8;font-size:13px;">
<span style="cursor:pointer;${item.done?'opacity:0.4;text-decoration:line-through':''}" onclick="toggleChecklistItem(${cl.id},${item._idx})">
${item.done?'☑':'☐'} ${escHtml(item.text)}
</span>
<button style="margin-left:auto;background:none;border:none;color:#ddd;cursor:pointer;font-size:12px;" onclick="deleteChecklistItem(${cl.id},${item._idx})">×</button>
</div>
`).join('');
itemsHtml += `<div style="display:flex;gap:6px;margin-top:8px;">
<textarea id="cl_input_${cl.id}" rows="1" placeholder="添加项目…多行粘贴" style="flex:1;padding:6px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:12px;outline:none;resize:none;font-family:inherit;"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,80)+'px'"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();addChecklistItem(${cl.id})}"></textarea>
<button onclick="addChecklistItem(${cl.id})" style="padding:6px 10px;background:#667eea;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;">+</button>
</div>`;
}
return `<div style="background:white;border-radius:12px;padding:14px;margin-bottom:10px;border:1px solid #eee;${cl.archived?'opacity:0.5':''}">
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;" onclick="clOpenIds.has(${cl.id})?clOpenIds.delete(${cl.id}):clOpenIds.add(${cl.id});renderChecklists();">
<span style="font-size:16px;">📋</span>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#333;">${escHtml(cl.name)}</div>
<div style="font-size:11px;color:#aaa;">${doneCount}/${total} 已完成</div>
</div>
<button style="background:none;border:none;font-size:11px;color:#999;cursor:pointer;" onclick="event.stopPropagation();uncheckAllChecklist(${cl.id})" title="全部取消勾选">↩</button>
<button style="background:none;border:none;font-size:11px;color:#999;cursor:pointer;" onclick="event.stopPropagation();archiveChecklist(${cl.id})" title="${cl.archived?'恢复':'存档'}">${cl.archived?'恢复':'📥'}</button>
<button style="background:none;border:none;font-size:11px;color:#ddd;cursor:pointer;" onclick="event.stopPropagation();deleteChecklist(${cl.id})">×</button>
<span style="color:#ccc;font-size:14px;transition:transform 0.2s;${isOpen?'transform:rotate(90deg)':''}"></span>
</div>
${isOpen ? '<div style="margin-top:10px;border-top:1px solid #f0f0f0;padding-top:8px;">'+itemsHtml+'</div>' : ''}
</div>`;
}
// ============================================================
// 7b. 目标系统
// ============================================================
let goals = JSON.parse(localStorage.getItem('sp_goals')) || [];
function saveGoals() { localStorage.setItem('sp_goals', JSON.stringify(goals)); markLocalDirty(); }
function openAddGoal() {
document.getElementById('goalEditTitle').textContent = '新目标';
document.getElementById('goalName').value = '';
document.getElementById('goalMonth').value = new Date().toISOString().slice(0,7);
document.getElementById('goalEditOverlay').classList.add('open');
document.getElementById('goalName').focus();
}
function closeGoalEdit() {
document.getElementById('goalEditOverlay').classList.remove('open');
}
function saveGoalEdit() {
const name = document.getElementById('goalName').value.trim();
const month = document.getElementById('goalMonth').value;
if (!name) return;
goals.push({
id: Date.now(),
title: name,
month: month || '',
subtasks: [],
createdAt: dateKey(new Date()),
status: 'active',
});
saveGoals(); closeGoalEdit(); renderGoals(); pushNow();
}
async function deleteGoal(id) {
if (!await showDialog('确定删除这个目标?所有拆解任务也会一起删除','confirm')) return;
goals = goals.filter(g => g.id !== id);
saveGoals(); renderGoals(); pushNow();
}
function addSubtask(goalId) {
const input = document.getElementById('subtask_input_' + goalId);
const text = input.value.trim();
if (!text) return;
const freq = document.getElementById('subtask_freq_' + goalId).value;
const goal = goals.find(g => g.id === goalId);
if (!goal) return;
goal.subtasks.push({ id: Date.now(), text, frequency: freq, checks: {} });
input.value = '';
saveGoals(); renderGoals(); pushNow();
}
async function deleteSubtask(goalId, subtaskId) {
if (!await showDialog('确定删除这个任务?','confirm')) return;
const goal = goals.find(g => g.id === goalId);
if (goal) {
goal.subtasks = goal.subtasks.filter(s => s.id !== subtaskId);
saveGoals(); renderGoals(); pushNow();
}
}
function toggleGoalCheck(goalId, subtaskId) {
const goal = goals.find(g => g.id === goalId);
if (!goal) return;
const sub = goal.subtasks.find(s => s.id === subtaskId);
if (!sub) return;
const today = dateKey(new Date());
const key = sub.frequency === 'weekly' ? getWeekKey(new Date()) : today;
if (!sub.checks) sub.checks = {};
sub.checks[key] = !sub.checks[key];
saveGoals(); renderGoals(); pushNow();
}
function getWeekKey(d) {
const onejan = new Date(d.getFullYear(),0,1);
const week = Math.ceil(((d - onejan) / 86400000 + onejan.getDay()+1) / 7);
return `${d.getFullYear()}-W${week}`;
}
function completeGoal(id) {
const goal = goals.find(g => g.id === id);
if (goal) { goal.status = goal.status === 'completed' ? 'active' : 'completed'; }
saveGoals(); renderGoals(); pushNow();
}
// 获取今日未完成的目标任务(给提醒用)
function getUncheckedGoalTasks() {
const today = dateKey(new Date());
const weekKey = getWeekKey(new Date());
const unchecked = [];
goals.filter(g => g.status === 'active').forEach(g => {
// 检查是否有拆解
if (g.subtasks.length === 0) {
unchecked.push({ text: `${g.title}」还没拆解任务`, goal: g.title });
return;
}
g.subtasks.forEach(s => {
const key = s.frequency === 'weekly' ? weekKey : today;
if (s.frequency === 'once') {
if (!s.checks?.done) unchecked.push({ text: s.text, goal: g.title });
} else if (!s.checks?.[key]) {
unchecked.push({ text: s.text, goal: g.title });
}
});
});
return unchecked;
}
let goalOpenIds = new Set();
function renderGoals() {
document.getElementById('goalDate').textContent = formatDate(new Date());
const today = dateKey(new Date());
const weekKey = getWeekKey(new Date());
// 今日打卡
const checksEl = document.getElementById('goalTodayChecks');
const activeGoals = goals.filter(g => g.status === 'active');
const todayTasks = [];
activeGoals.forEach(g => {
g.subtasks.forEach(s => {
if (s.frequency === 'once' && s.checks?.done) return; // 一次性已完成跳过
const key = s.frequency === 'weekly' ? weekKey : today;
const done = s.frequency === 'once' ? s.checks?.done : s.checks?.[key];
todayTasks.push({ goalId: g.id, subtaskId: s.id, text: s.text, goalTitle: g.title, done, freq: s.frequency });
});
});
if (todayTasks.length === 0) {
checksEl.innerHTML = '<div style="color:#ccc;font-size:13px;padding:10px 0;">没有目标任务,点右上角 + 新目标</div>';
} else {
checksEl.innerHTML = todayTasks.map(t => `
<div class="health-today-item ${t.done?'done':''}" onclick="toggleGoalCheck(${t.goalId},${t.subtaskId})" style="justify-content:space-between;">
<span>${t.done?'✓ ':''}${t.text}</span>
<span style="font-size:10px;color:#aaa;">${t.goalTitle}</span>
</div>
`).join('');
}
// 目标列表
const listEl = document.getElementById('goalList');
if (goals.length === 0) {
listEl.innerHTML = '<div style="color:#ccc;font-size:13px;padding:10px 0;">还没有目标</div>';
return;
}
listEl.innerHTML = goals.map(g => {
const isOpen = goalOpenIds.has(g.id);
const isDone = g.status === 'completed';
const subtasksHtml = g.subtasks.map(s => {
const key = s.frequency === 'weekly' ? weekKey : today;
const checked = s.frequency === 'once' ? s.checks?.done : s.checks?.[key];
const freqLabel = s.frequency === 'daily' ? '每天' : s.frequency === 'weekly' ? '每周' : '一次';
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #f5f5f5;font-size:13px;">
<span style="cursor:pointer;${checked?'opacity:0.4':''}" onclick="toggleGoalCheck(${g.id},${s.id})">${checked?'✅':'⬜'} ${s.text}</span>
<span style="font-size:10px;color:#bbb;margin-left:auto;">${freqLabel}</span>
<button style="background:none;border:none;color:#ddd;cursor:pointer;font-size:12px;" onclick="deleteSubtask(${g.id},${s.id})">×</button>
</div>`;
}).join('');
return `<div style="background:${isDone?'#f8f8f8':'white'};border-radius:12px;padding:14px;margin-bottom:10px;border:1px solid #eee;${isDone?'opacity:0.5':''}">
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;" onclick="goalOpenIds.has(${g.id})?goalOpenIds.delete(${g.id}):goalOpenIds.add(${g.id});renderGoals();">
<span style="font-size:16px;">🎯</span>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#333;">${escHtml(g.title)}</div>
<div style="font-size:11px;color:#aaa;">${g.month||'无期限'} · ${g.subtasks.length}个任务</div>
</div>
<button style="background:none;border:none;font-size:12px;color:#667eea;cursor:pointer;" onclick="event.stopPropagation();completeGoal(${g.id})">${isDone?'恢复':'完成'}</button>
<button style="background:none;border:none;font-size:12px;color:#ddd;cursor:pointer;" onclick="event.stopPropagation();deleteGoal(${g.id})">×</button>
<span style="color:#ccc;font-size:14px;transition:transform 0.2s;${isOpen?'transform:rotate(90deg)':''}"></span>
</div>
${isOpen ? `<div style="margin-top:10px;border-top:1px solid #f0f0f0;padding-top:10px;">
${subtasksHtml || '<div style="color:#ccc;font-size:12px;">还没有拆解任务</div>'}
<div style="display:flex;gap:6px;margin-top:8px;">
<input type="text" id="subtask_input_${g.id}" placeholder="添加任务…" style="flex:1;padding:6px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:12px;outline:none;"
onkeydown="if(event.key==='Enter'&&!event.isComposing)addSubtask(${g.id})">
<select id="subtask_freq_${g.id}" style="padding:6px;border:1px solid #e0e0e0;border-radius:8px;font-size:11px;">
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="once">一次</option>
</select>
<button onclick="addSubtask(${g.id})" style="padding:6px 10px;background:#667eea;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;">+</button>
</div>
</div>` : ''}
</div>`;
}).join('');
}
// ============================================================
// 8a. Bug 追踪(带优先级 + 活动日志)
// ============================================================
let bugs = JSON.parse(localStorage.getItem('sp_bugs')) || [];
function saveBugs() { localStorage.setItem('sp_bugs', JSON.stringify(bugs)); markLocalDirty(); }
const BUG_PRIORITY = [
{ name:'紧急', color:'#ef4444', bg:'#fef2f2' },
{ name:'重要', color:'#f59e0b', bg:'#fffbeb' },
{ name:'一般', color:'#22c55e', bg:'#f0fdf4' },
];
const BUG_STATUS = {
open: { label:'待处理', color:'#e65100', bg:'#fff3e0' },
testing: { label:'待测试', color:'#d97706', bg:'#fffbeb' },
fixed: { label:'已修复', color:'#22c55e', bg:'#f0fdf4' },
closed: { label:'已关闭', color:'#aaa', bg:'#f8f8f8' },
};
function bugLog(bug, action) {
if (!bug.log) bug.log = [];
bug.log.push({
action,
time: new Date().toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }),
});
}
async function openAddBug() {
const desc = await showDialog('描述这个 Bug', 'prompt', '');
if (!desc || !desc.trim()) return;
const bug = {
id: Date.now(),
text: desc.trim(),
status: 'open',
priority: 2, // 默认一般
log: [],
createdAt: dateKey(new Date()),
time: new Date().toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }),
};
bugLog(bug, '创建');
bugs.unshift(bug);
saveBugs(); renderBugs(); pushNow();
}
function setBugStatus(id, status) {
const bug = bugs.find(b => b.id === id);
if (!bug) return;
bug.status = status;
bugLog(bug, `${BUG_STATUS[status]?.label || status}`);
saveBugs(); renderBugs(); pushNow();
}
let _priTimer = {};
function cycleBugPriority(id, dotEl) {
const bug = bugs.find(b => b.id === id);
if (!bug) return;
if (!_priTimer[id]) _priTimer[id] = { orig: bug.priority ?? 2 };
bug.priority = ((bug.priority ?? 2) + 1) % 3;
// 只更新圆点颜色,不重排
const pri = BUG_PRIORITY[bug.priority];
if (dotEl) { dotEl.style.background = pri.color; dotEl.title = pri.name + '(点击切换)'; }
clearTimeout(_priTimer[id].tid);
_priTimer[id].tid = setTimeout(() => {
if (bug.priority !== _priTimer[id].orig) {
bugLog(bug, `优先级 → ${BUG_PRIORITY[bug.priority].name}`);
}
delete _priTimer[id];
saveBugs(); renderBugs(); pushNow();
}, 1000);
}
async function addBugNote(id) {
const note = await showDialog('添加备注', 'prompt', '');
if (!note || !note.trim()) return;
const bug = bugs.find(b => b.id === id);
if (!bug) return;
bugLog(bug, `备注:${note.trim()}`);
saveBugs(); renderBugs(); pushNow();
}
async function deleteBug(id) {
if (!await showDialog('确定删除这条 Bug','confirm')) return;
bugs = bugs.filter(b => b.id !== id);
saveBugs(); renderBugs(); pushNow();
}
function copyBug(id) {
const b = bugs.find(x => x.id === id);
if (!b) return;
navigator.clipboard.writeText(b.text).then(() => {
showToast('已复制到剪贴板');
});
}
let bugOpenIds = new Set();
function renderBugs() {
const el = document.getElementById('bugList');
if (!el) return;
// 按优先级排序,同优先级按时间
const sortedBugs = [...bugs].sort((a,b) => (a.priority??2) - (b.priority??2));
const active = sortedBugs.filter(b => b.status !== 'closed');
const closed = sortedBugs.filter(b => b.status === 'closed');
let html = '';
if (active.length === 0 && closed.length === 0) {
html = '<div style="text-align:center;color:#ccc;padding:30px;font-size:13px;">没有 Bug太棒了🎉</div>';
} else {
active.forEach(b => { html += renderBugCard(b); });
}
if (closed.length > 0) {
html += `<div style="font-size:12px;color:#aaa;margin:16px 0 6px;cursor:pointer;user-select:none;" onclick="const e=document.getElementById('closedBugs');e.style.display=e.style.display==='none'?'block':'none'">已关闭 (${closed.length}) </div>`;
html += '<div id="closedBugs" style="display:none;">';
closed.forEach(b => { html += renderBugCard(b, true); });
html += '</div>';
}
el.innerHTML = html;
}
function renderBugCard(b, isClosed) {
const pri = BUG_PRIORITY[b.priority ?? 2];
const st = BUG_STATUS[b.status] || BUG_STATUS.open;
const isOpen = bugOpenIds.has(b.id);
// 状态按钮 — 纯文字风格
const bbs = 'font-size:11px;padding:3px 8px;border-radius:6px;cursor:pointer;border:none;';
let btns = '';
if (!isClosed) {
const statusBtns = [];
if (b.status === 'open') statusBtns.push(`<button style="${bbs}background:#fffbeb;color:#d97706;" onclick="event.stopPropagation();setBugStatus(${b.id},'testing')">测试中</button>`);
if (b.status === 'open' || b.status === 'testing') statusBtns.push(`<button style="${bbs}background:#f0fdf4;color:#16a34a;" onclick="event.stopPropagation();setBugStatus(${b.id},'fixed')">已修复</button>`);
if (b.status === 'fixed') statusBtns.push(`<button style="${bbs}background:#eff6ff;color:#667eea;" onclick="event.stopPropagation();setBugStatus(${b.id},'closed')">关闭</button>`);
statusBtns.push(`<button style="${bbs}background:#f5f5f5;color:#888;" onclick="event.stopPropagation();addBugNote(${b.id})">备注</button>`);
statusBtns.push(`<button style="${bbs}background:#f5f5f5;color:#888;" onclick="event.stopPropagation();copyBug(${b.id})">复制</button>`);
statusBtns.push(`<button style="${bbs}background:none;color:#ccc;" onclick="event.stopPropagation();deleteBug(${b.id})">删除</button>`);
btns = `<div style="display:flex;gap:4px;flex-wrap:wrap;">${statusBtns.join('')}</div>`;
}
// 活动日志
let logHtml = '';
if (isOpen && b.log && b.log.length > 0) {
logHtml = `<div style="margin-top:8px;border-top:1px solid #f0f0f0;padding-top:6px;">
${b.log.map(l => `<div style="font-size:11px;color:#999;padding:2px 0;">
<span style="color:#bbb;">${l.time}</span> ${escHtml(l.action)}
</div>`).join('')}
</div>`;
}
return `<div style="background:${isClosed?'#f8f8f8':st.bg};border-left:3px solid ${isClosed?'#ddd':st.color};border-radius:10px;padding:10px 14px;margin-bottom:6px;${isClosed?'opacity:0.5':''}">
<div style="cursor:pointer;" onclick="bugOpenIds.has(${b.id})?bugOpenIds.delete(${b.id}):bugOpenIds.add(${b.id});renderBugs();">
<div style="display:flex;align-items:flex-start;gap:8px;">
<span style="width:10px;height:10px;border-radius:50%;background:${pri.color};flex-shrink:0;margin-top:4px;cursor:pointer;" onclick="event.stopPropagation();cycleBugPriority(${b.id},this)" title="${pri.name}(点击切换)"></span>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;color:#444;word-break:break-word;${isClosed?'text-decoration:line-through;color:#aaa':''}">${escHtml(b.text)}</div>
<div style="font-size:10px;color:#bbb;margin-top:2px;">
${b.time} · <span style="color:${st.color}">${st.label}</span> · ${pri.name}
${b.log?.length ? ` · ${b.log.length}条记录` : ''}
</div>
</div>
</div>
${btns ? `<div style="margin-top:6px;padding-left:18px;">${btns}</div>` : ''}
</div>
${logHtml}
</div>`;
}
// ============================================================
// 8b. 个人文档
// ============================================================
const DEFAULT_DOCS = [
{
id: 'doc_sleep',
name: '睡眠记录',
icon: '🌙',
keywords: ['睡','入睡','睡觉','睡着','上床','熬夜'],
extractRule: 'sleep',
entries: [],
},
{
id: 'doc_reading',
name: '读书记录',
icon: '📖',
keywords: ['读完','看完','读了','在读','这本书','书评','阅读'],
extractRule: 'book',
entries: [],
},
{
id: 'doc_ideas',
name: '灵感集',
icon: '💡',
keywords: ['灵感','突然想到','idea','想到一个','好主意'],
extractRule: 'none',
entries: [],
},
];
let personalDocs = JSON.parse(localStorage.getItem('sp_docs')) || DEFAULT_DOCS;
// 确保灵感文档存在(旧用户升级)
if (!personalDocs.find(d => d.id === 'doc_ideas')) {
personalDocs.push({ id:'doc_ideas', name:'灵感集', icon:'💡', keywords:['灵感','突然想到','idea','想到一个','好主意'], extractRule:'none', entries:[] });
saveDocs();
}
let editingDocId = null;
let docEmoji = '📝';
const DOC_EMOJIS = ['📖','🌙','💪','🍽️','💰','🎯','📝','💊','🧘','🎵','🏃','🧴','💡','📊','🌿','✈️','❤️','🎬'];
function saveDocs() { localStorage.setItem('sp_docs', JSON.stringify(personalDocs)); markLocalDirty(); }
// 自动路由:从随手记保存时调用
function autoRouteNote(text, noteId, noteTime) {
const matched = [];
for (const doc of personalDocs) {
const hit = doc.keywords.some(kw => text.includes(kw));
if (!hit) continue;
// 如果是目标描述,跳过睡眠文档路由
if (doc.extractRule === 'sleep' && /目标/.test(text)) continue;
let extracted = text;
let extractedHighlight = '';
if (doc.extractRule === 'sleep') {
// 提取睡眠时间:匹配 "9点半", "9:30", "22:30", "9点", "九点" etc
const timeMatch = text.match(/(\d{1,2})[点:](\d{1,2})?/) ||
text.match(/([\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341]+)点/);
if (timeMatch) {
const cnNum = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'十一':11,'十二':12};
let hour = cnNum[timeMatch[1]] || parseInt(timeMatch[1]);
let min = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
if (text.includes('半') && min === 0) min = 30;
// 睡眠时间转换9-11→晚上12→凌晨0点1-8→凌晨
if (hour >= 9 && hour <= 11) hour += 12;
else if (hour === 12) hour = 0;
// 取整到10分钟
min = Math.round(min / 10) * 10;
if (min === 60) { min = 0; hour++; }
if (hour >= 24) hour -= 24;
extractedHighlight = `${hour}:${min.toString().padStart(2,'0')}`;
// 写入睡眠记录 Tab 的数据
const startStr = `${hour.toString().padStart(2,'0')}:${min.toString().padStart(2,'0')}`;
const isYesterday = /昨/.test(text);
const recDate = new Date();
if (isYesterday) recDate.setDate(recDate.getDate() - 1);
const recDateStr = dateKey(recDate);
const existIdx = sleepData.findIndex(r => r.date === recDateStr);
if (existIdx >= 0) {
sleepData[existIdx].start = startStr;
} else {
sleepData.push({ date: recDateStr, start: startStr });
}
sleepData.sort((a,b) => b.date.localeCompare(a.date));
saveSleep();
syncSleepToBuddy({ date: recDateStr, start: startStr });
}
} else if (doc.extractRule === 'book') {
// 提取书名:多种模式
let bookName = '';
let bookStatus = '';
// 1. 《xxx》
const bracketMatch = text.match(/《(.+?)》/);
if (bracketMatch) bookName = bracketMatch[1];
// 2. "开始读/在读/正在读 xxx", "读完/看完 xxx"
if (!bookName) {
const readMatch = text.match(/(?:开始读|在读|正在读|读完了?|看完了?|听完了?|读了|看了)\s*[《]?([^,。.!?\s《》]{2,20})[》]?/);
if (readMatch) bookName = readMatch[1].replace(/[了的]$/, '');
}
// 判断状态
if (/读完|看完|听完/.test(text)) {
bookStatus = '已读';
} else if (/开始读|开始看|开始听|在读|正在读|正在看|正在听/.test(text)) {
bookStatus = '在读';
} else if (/想读|准备读|打算读|想看|准备看/.test(text)) {
bookStatus = '待读';
}
extractedHighlight = bookName;
var entryStatus = bookStatus;
// 如果标记为已读,把同名的「在读」条目移除
if (bookStatus === '已读' && bookName) {
doc.entries = doc.entries.filter(e =>
!(e.status === '在读' && e.extracted && e.extracted === bookName)
);
}
}
doc.entries.unshift({
text: extracted,
extracted: extractedHighlight,
status: entryStatus || '',
noteId,
time: noteTime,
date: dateKey(new Date()),
});
matched.push({ name: doc.name, icon: doc.icon });
}
if (matched.length > 0) {
saveDocs();
}
return matched;
}
let openDocIds = new Set();
function renderDocs() {
const container = document.getElementById('docCards');
container.innerHTML = '';
personalDocs.forEach(doc => {
const isOpen = openDocIds.has(doc.id);
const count = doc.entries.length;
let entriesHtml = '';
if (count === 0) {
entriesHtml = '<div class="doc-empty">还没有记录,在随手记中输入包含关键词的内容会自动归档到这里</div>';
} else {
// 按 status 分组显示
const STATUS_ORDER = ['在读','待读','在听','已读','弃读'];
const STATUS_STYLE = {
'在读': { bg:'#e8f5e9', fg:'#2e7d32' },
'待读': { bg:'#fff3e0', fg:'#e65100' },
'在听': { bg:'#e3f2fd', fg:'#1565c0' },
'已读': { bg:'#f5f5f5', fg:'#888' },
'弃读': { bg:'#f5f5f5', fg:'#ccc' },
};
const hasStatus = doc.entries.some(e => e.status);
if (hasStatus) {
const groups = {};
doc.entries.forEach((e, i) => {
const s = e.status || '其他';
if (!groups[s]) groups[s] = [];
groups[s].push({ ...e, _idx: i });
});
const order = [...STATUS_ORDER, ...Object.keys(groups).filter(k => !STATUS_ORDER.includes(k))];
order.forEach(status => {
const items = groups[status];
if (!items || items.length === 0) return;
const st = STATUS_STYLE[status] || { bg:'#f5f5f5', fg:'#888' };
entriesHtml += `<div style="margin-top:12px;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<span style="background:${st.bg};color:${st.fg};font-size:12px;font-weight:600;padding:3px 10px;border-radius:6px;">${status}</span>
<span style="font-size:11px;color:#ccc;">${items.length}本</span>
</div>`;
entriesHtml += items.map(e => {
const isDrop = status === '弃读';
return `<div class="doc-entry" ${isDrop?'style="opacity:0.4"':''}>
<div class="doc-entry-date">${e.date || ''}</div>
<div style="flex:1;cursor:pointer;" onclick="editDocEntry('${doc.id}',${e._idx})">
${e.extracted ? `<div class="doc-entry-extracted">${escHtml(e.extracted)}</div>` : ''}
${e.text !== e.extracted ? `<div class="doc-entry-source" style="color:#999;font-size:12px;">${escHtml(e.text)}</div>` : ''}
</div>
<button style="font-size:10px;color:#aaa;background:none;border:1px solid #e0e0e0;border-radius:4px;padding:2px 6px;cursor:pointer;" onclick="event.stopPropagation();changeBookStatus('${doc.id}',${e._idx})">${status}</button>
<button class="doc-entry-del" onclick="deleteDocEntry('${doc.id}',${e._idx})">×</button>
</div>`;
}).join('');
});
} else {
entriesHtml = doc.entries.map((e, i) => `
<div class="doc-entry">
<div class="doc-entry-date">${e.time || e.date || ''}</div>
<div style="flex:1;cursor:pointer;" onclick="editDocEntry('${doc.id}',${i})">
${e.extracted ? `<div class="doc-entry-extracted">${escHtml(e.extracted)}</div>` : ''}
<div class="doc-entry-text">${escHtml(e.text)}</div>
</div>
<button class="doc-entry-del" onclick="deleteDocEntry('${doc.id}',${i})">×</button>
</div>
`).join('');
}
// 文档备注
if (doc.notes) {
entriesHtml += `<div style="margin-top:12px;padding:10px 14px;background:#fff8e7;border-radius:8px;font-size:12px;color:#8a6c00;">📌 ${escHtml(doc.notes)}</div>`;
}
}
container.innerHTML += `
<div class="doc-card ${isOpen ? 'open' : ''}" id="dc-${doc.id}">
<div class="doc-card-header" onclick="toggleDoc('${doc.id}')">
<div class="doc-card-icon">${doc.icon}</div>
<div class="doc-card-info">
<div class="doc-card-name">${escHtml(doc.name)}</div>
<div class="doc-card-meta">关键词:${doc.keywords.join(', ')}</div>
</div>
<div class="doc-card-actions" onclick="event.stopPropagation()">
<button onclick="openEditDoc('${doc.id}')" title="编辑">✎</button>
<button onclick="deleteDoc('${doc.id}')" title="删除">×</button>
</div>
<span class="doc-card-count">${count}</span>
<span class="doc-card-arrow"></span>
</div>
<div class="doc-card-body">
${entriesHtml}
<div style="display:flex;gap:6px;margin-top:10px;border-top:1px solid #f0f0f0;padding-top:10px;">
<input type="text" id="docAdd_${doc.id}" placeholder="手动添加记录…" style="flex:1;padding:6px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:12px;outline:none;"
onkeydown="if(event.key==='Enter'&&!event.isComposing)addDocEntry('${doc.id}')">
<button onclick="addDocEntry('${doc.id}')" style="padding:6px 10px;background:#667eea;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;">+</button>
</div>
</div>
</div>`;
});
}
function toggleDoc(id) {
if (openDocIds.has(id)) openDocIds.delete(id);
else openDocIds.add(id);
renderDocs();
}
function dropBook(docId, idx) {
const doc = personalDocs.find(d => d.id === docId);
if (!doc || !doc.entries[idx]) return;
doc.entries[idx].status = '弃读';
saveDocs(); renderDocs(); pushNow();
}
function addDocEntry(docId) {
const input = document.getElementById('docAdd_' + docId);
const text = input.value.trim();
if (!text) return;
const doc = personalDocs.find(d => d.id === docId);
if (!doc) return;
const now = new Date();
// 智能状态:读书文档手动添加默认"在读"
const autoStatus = (doc.extractRule === 'book') ? '在读' : '';
doc.entries.unshift({
text,
extracted: text,
status: autoStatus,
noteId: 0,
time: now.toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }),
date: dateKey(now),
});
input.value = '';
saveDocs(); renderDocs(); pushNow();
setTimeout(() => document.getElementById('docAdd_' + docId)?.focus(), 50);
}
async function editDocEntry(docId, idx) {
const doc = personalDocs.find(d => d.id === docId);
if (!doc || !doc.entries[idx]) return;
const e = doc.entries[idx];
const newText = await showDialog('编辑内容', 'prompt', e.extracted || e.text);
if (newText === null || !newText.trim()) return;
e.extracted = newText.trim();
e.text = newText.trim();
saveDocs(); renderDocs(); pushNow();
}
async function changeBookStatus(docId, idx) {
const doc = personalDocs.find(d => d.id === docId);
if (!doc || !doc.entries[idx]) return;
const statuses = ['在读','待读','在听','已读','弃读'];
const current = doc.entries[idx].status || '';
const choice = await showDialog(`当前状态:${current}\n选择新状态(输入数字):\n1. 在读\n2. 待读\n3. 在听\n4. 已读\n5. 弃读`, 'prompt', '');
if (!choice) return;
const n = parseInt(choice);
if (n >= 1 && n <= 5) {
doc.entries[idx].status = statuses[n - 1];
saveDocs(); renderDocs(); pushNow();
}
}
async function deleteDocEntry(docId, idx) {
if (!await showDialog('确定删除这条记录?','confirm')) return;
const doc = personalDocs.find(d => d.id === docId);
if (doc) { doc.entries.splice(idx, 1); saveDocs(); renderDocs(); pushNow(); }
}
async function deleteDoc(id) {
if (!await showDialog('确定删除这个文档?所有记录也会一起删除','confirm')) return;
personalDocs = personalDocs.filter(d => d.id !== id);
saveDocs(); renderDocs(); pushNow();
}
function openAddDoc() {
editingDocId = null;
docEmoji = '📝';
document.getElementById('docEditTitle').textContent = '新建文档';
document.getElementById('docName').value = '';
document.getElementById('docKeywords').value = '';
document.getElementById('docExtractRule').value = 'none';
renderDocEmojis();
document.getElementById('docEditOverlay').classList.add('open');
document.getElementById('docName').focus();
}
function openEditDoc(id) {
const doc = personalDocs.find(d => d.id === id);
if (!doc) return;
editingDocId = id;
docEmoji = doc.icon;
document.getElementById('docEditTitle').textContent = '编辑文档';
document.getElementById('docName').value = doc.name;
document.getElementById('docKeywords').value = doc.keywords.join(', ');
document.getElementById('docExtractRule').value = doc.extractRule || 'none';
renderDocEmojis();
document.getElementById('docEditOverlay').classList.add('open');
}
function closeDocEdit() {
document.getElementById('docEditOverlay').classList.remove('open');
}
function renderDocEmojis() {
const row = document.getElementById('docEmojiRow');
row.innerHTML = '';
DOC_EMOJIS.forEach(e => {
const d = document.createElement('div');
d.className = 'emoji-option' + (e === docEmoji ? ' selected' : '');
d.textContent = e;
d.onclick = () => { docEmoji = e; renderDocEmojis(); };
row.appendChild(d);
});
}
function saveDocEdit() {
const name = document.getElementById('docName').value.trim();
const keywords = document.getElementById('docKeywords').value.split(/[,]/).map(s => s.trim()).filter(Boolean);
const extractRule = document.getElementById('docExtractRule').value;
if (!name || keywords.length === 0) return;
if (editingDocId) {
const doc = personalDocs.find(d => d.id === editingDocId);
if (doc) {
doc.name = name;
doc.icon = docEmoji;
doc.keywords = keywords;
doc.extractRule = extractRule;
}
} else {
personalDocs.push({
id: 'doc_' + Date.now(),
name,
icon: docEmoji,
keywords,
extractRule,
entries: [],
});
}
saveDocs();
closeDocEdit();
renderDocs(); pushNow();
}
// ============================================================
// 7. 睡眠记录
// ============================================================
let sleepData = JSON.parse(localStorage.getItem('sp_sleep')) || [];
// sleepData: [{ date:'2026-03-25', start:'22:30' }, ...]
let sleepViewMonth = new Date();
function saveSleep() {
localStorage.setItem('sp_sleep', JSON.stringify(sleepData)); markLocalDirty();
}
// 同步单条睡眠记录到 buddy
function syncSleepToBuddy(record) {
const buddyUser = getBuddyUsername();
if (!buddyUser) return;
fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user: buddyUser, action:'record', record })
}).catch(()=>{});
}
// 从 buddy 删除单条睡眠记录
function deleteSleepFromBuddy(date) {
const buddyUser = getBuddyUsername();
if (!buddyUser) return;
fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user: buddyUser, action:'delete-record', date })
}).catch(()=>{});
}
// 获取 buddy 用户名(从 buddy 登录 session 或 localStorage 配置)
function getBuddyUsername() {
const session = JSON.parse(localStorage.getItem('buddy_session') || 'null');
if (session && session.username) return session.username;
return localStorage.getItem('sp_buddy_username') || 'Hera';
}
// 目标入睡时间(从 buddy 数据同步)
let plannerSleepTargetTime = localStorage.getItem('sp_sleep_target') || '22:00';
function initPlannerSleepTarget() {
const el = document.getElementById('plannerSleepTarget');
if (el) el.textContent = plannerSleepTargetTime;
// 从 buddy 服务拉最新目标
const buddyUser = getBuddyUsername();
if (buddyUser) {
fetch('/api/sleep-buddy').then(r=>r.json()).then(data => {
const t = (data.targets || {})[buddyUser];
if (t) { plannerSleepTargetTime = t; localStorage.setItem('sp_sleep_target', t); if (el) el.textContent = t; }
}).catch(()=>{});
}
}
async function setPlannerSleepTarget() {
const t = await showDialog('设置目标入睡时间(如 22:00', 'prompt', plannerSleepTargetTime);
if (!t || !t.trim()) return;
const m = t.trim().match(/^(\d{1,2})[:\uff1a](\d{2})$/);
if (!m) { showToast('格式不对,请输入如 22:00'); return; }
const time = `${m[1].padStart(2,'0')}:${m[2]}`;
plannerSleepTargetTime = time;
localStorage.setItem('sp_sleep_target', time);
const el = document.getElementById('plannerSleepTarget');
if (el) el.textContent = time;
// 同步到 buddy
const buddyUser = getBuddyUsername();
if (buddyUser) {
fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user: buddyUser, action:'set-target', target: time })
}).catch(()=>{});
}
renderSleep();
}
async function parseSleepInput() {
const input = document.getElementById('sleepTextInput');
const hint = document.getElementById('sleepParseHint');
const raw = input.value.trim();
if (!raw) return;
const now = new Date();
const thisYear = now.getFullYear();
const thisMonth = now.getMonth() + 1;
const today = now.getDate();
let dateStr = '';
let hour = -1, min = 0;
// ===== 解析日期 =====
// "昨天/昨晚" → 昨天
if (/昨/.test(raw)) {
const d = new Date(now);
d.setDate(d.getDate() - 1);
dateStr = dateKey(d);
}
// "3.26" / "3/26" / "3-26" / "03.26"
const dateMatch = raw.match(/(\d{1,2})[\.\/\-](\d{1,2})/);
if (dateMatch) {
const dm = parseInt(dateMatch[1]);
const dd = parseInt(dateMatch[2]);
// 判断是月.日还是有可能是其他
if (dm >= 1 && dm <= 12 && dd >= 1 && dd <= 31) {
// 如果月份大于当前月,说明是去年
const yr = dm > thisMonth ? thisYear - 1 : thisYear;
dateStr = `${yr}-${dm.toString().padStart(2,'0')}-${dd.toString().padStart(2,'0')}`;
}
}
// "25号" / "25日"
if (!dateStr) {
const dayMatch = raw.match(/(\d{1,2})[号日]/);
if (dayMatch) {
const dd = parseInt(dayMatch[1]);
dateStr = `${thisYear}-${thisMonth.toString().padStart(2,'0')}-${dd.toString().padStart(2,'0')}`;
}
}
// 没匹配到日期 → 今天
if (!dateStr) {
dateStr = dateKey(now);
}
// ===== 解析时间 =====
const cnNum = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'十一':11,'十二':12};
// "11:20" / "1120" / "23:30" / "11点20" / "11点半"
const timeMatch = raw.match(/(\d{1,2})[点:](\d{1,2})/) ||
raw.match(/(\d{1,2})点半/) ||
raw.match(/(\d{1,2})点/);
if (timeMatch) {
hour = parseInt(timeMatch[1]);
if (timeMatch[0].includes('半')) {
min = 30;
} else if (timeMatch[2]) {
min = parseInt(timeMatch[2]);
}
}
// 中文数字: "九点半"
if (hour < 0) {
const cnMatch = raw.match(/([\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341]+)点/);
if (cnMatch && cnNum[cnMatch[1]]) {
hour = cnNum[cnMatch[1]];
if (raw.includes('半')) min = 30;
}
}
if (hour < 0) {
hint.style.color = '#ef4444';
hint.textContent = '没能识别出时间试试3.2611点20 或 昨晚10:30';
return;
}
// 睡眠时间智能转换(这是睡眠记录,不是一般时间)
// 12点 = 半夜12点 = 0:00不是中午
// 9-11点 → 晚上 21:00-23:00
// 12点 → 凌晨 0:00
// 1-8点 → 凌晨,保持不变
if (hour >= 9 && hour <= 11) {
hour += 12; // 9→21, 10→22, 11→23
} else if (hour === 12) {
hour = 0; // 12点 = 半夜0点
}
// 分钟取整到 10 分钟
min = Math.round(min / 10) * 10;
if (min === 60) { min = 0; hour++; }
if (hour >= 24) hour -= 24;
const startStr = `${hour.toString().padStart(2,'0')}:${min.toString().padStart(2,'0')}`;
// 写入(检查是否已存在)
const idx = sleepData.findIndex(r => r.date === dateStr);
if (idx >= 0) {
const old = sleepData[idx].start;
if (!await showDialog(`${dateStr} 已有记录(${fmtSleepTime(old)}),是否覆盖为 ${fmtSleepTime(startStr)}`,'confirm')) {
return;
}
sleepData[idx] = { date: dateStr, start: startStr };
} else {
sleepData.push({ date: dateStr, start: startStr });
}
sleepData.sort((a,b) => b.date.localeCompare(a.date));
saveSleep();
syncSleepToBuddy({ date: dateStr, start: startStr });
hint.style.color = '#22c55e';
hint.textContent = `✓ 已记录:${dateStr} 入睡 ${fmtSleepTime(startStr)}`;
input.value = '';
renderSleep(); pushNow();
}
async function manageBuddyUsers() {
try {
const resp = await fetch('/api/sleep-buddy');
const data = await resp.json();
const users = Object.keys(data.users || {});
if (users.length === 0) { alert('没有睡眠用户'); return; }
const userList = users.map(u => `${u}`).join('\n');
const name = await showDialog(`当前用户:\n${userList}\n\n输入要删除的用户名:`, 'prompt', '');
if (!name || !name.trim()) return;
if (name.trim() === 'Hera') { alert('不能删自己'); return; }
if (!users.includes(name.trim())) { alert('用户不存在'); return; }
if (!await showDialog(`确定删除用户「${name.trim()}TA 的所有睡眠数据也会一起删除`, 'confirm')) return;
const pwHash = await hashStr(await showDialog('输入你的 Planner 密码确认:', 'prompt', ''));
const delResp = await fetch('/api/buddy-delete-user', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ adminHash: pwHash, username: name.trim() }),
});
const r = await delResp.json();
alert(r.ok ? '已删除' : (r.error || '删除失败'));
} catch(e) { alert('操作失败'); }
}
async function goSleepNow() {
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 startStr = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
const recDate = dateKey(now);
// 记录到本地睡眠
const idx = sleepData.findIndex(r => r.date === recDate);
if (idx >= 0) sleepData[idx].start = startStr;
else sleepData.push({ date: recDate, start: startStr });
sleepData.sort((a,b) => b.date.localeCompare(a.date));
saveSleep(); renderSleep(); pushNow();
syncSleepToBuddy({ date: recDate, start: startStr });
// 通知好友
const buddyUser = getBuddyUsername();
if (buddyUser) {
fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user: buddyUser, action:'sleep-now' })
}).catch(()=>{});
}
// UI 反馈
const btn = document.getElementById('plannerSleepBtn');
btn.textContent = '🌙 晚安!已通知好友';
btn.style.opacity = '0.6';
setTimeout(() => { btn.textContent = '🌙 我去睡觉啦'; btn.style.opacity = '1'; }, 3000);
}
async function loadBuddySleep() {
const body = document.getElementById('buddySleepBody');
if (!body) return;
try {
const resp = await fetch('/api/sleep-buddy');
const data = await resp.json();
const users = data.users || {};
const others = Object.keys(users).filter(n => n !== 'Hera');
if (others.length === 0) {
body.innerHTML = '<div style="color:#ccc;font-size:13px;padding:10px 0;">还没有好友加入<br><span style="font-size:11px;">分享 planner.oci.euphon.net/sleep 给好友</span></div>';
return;
}
let html = '';
others.forEach(name => {
const recs = users[name] || [];
if (recs.length === 0) return;
const last7 = recs.slice(0, 7);
const allMins = last7.map(r => sleepTimeToMins(r.start));
const avg = Math.round(allMins.reduce((a,b)=>a+b,0)/allMins.length);
const early = last7.filter(r => sleepTimeToMins(r.start) <= 120).length;
const minsToStr = off => { let t=off+20*60; if(t>=1440)t-=1440; return `${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`; };
const latest = recs[0];
html += `<div style="padding:10px 0;border-bottom:1px solid #f5f5f5;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-weight:600;color:#444;">${escHtml(name)}</span>
<span style="font-size:11px;color:#aaa;">近7天均 ${fmtSleepTime(minsToStr(avg))} · ${early}天22点前</span>
</div>
<div style="font-size:12px;color:#888;margin-top:4px;">最近:${latest.date.slice(5)} ${fmtSleepTime(latest.start)}</div>
<div style="display:flex;gap:3px;margin-top:6px;">
${last7.reverse().map(r => {
const mins = sleepTimeToMins(r.start);
const color = mins <= 120 ? '#22c55e' : mins <= 180 ? '#f59e0b' : '#ef4444';
return `<div title="${r.date} ${fmtSleepTime(r.start)}" style="width:12px;height:12px;border-radius:3px;background:${color};"></div>`;
}).join('')}
</div>
</div>`;
});
body.innerHTML = html;
} catch(e) {
body.innerHTML = '<div style="color:#ccc;font-size:13px;">加载失败</div>';
}
}
async function deleteSleepRecord(date) {
if (!await showDialog(`确定删除 ${date} 的睡眠记录?`,'confirm')) return;
sleepData = sleepData.filter(r => r.date !== date);
// 标记删除,让服务器合并时也删掉
const deleted = JSON.parse(localStorage.getItem('sp_sleep_deleted') || '[]');
if (!deleted.includes(date)) deleted.push(date);
localStorage.setItem('sp_sleep_deleted', JSON.stringify(deleted));
saveSleep();
deleteSleepFromBuddy(date);
renderSleep();
await pushNow();
}
function changeSleepMonth(delta) {
if (sleepYearView) {
sleepViewMonth.setFullYear(sleepViewMonth.getFullYear() + delta);
} else {
sleepViewMonth.setMonth(sleepViewMonth.getMonth() + delta);
}
renderSleep();
}
let sleepYearView = false;
function toggleSleepYearView() {
sleepYearView = !sleepYearView;
document.getElementById('sleepViewBtn').classList.toggle('active', sleepYearView);
document.getElementById('sleepViewBtn').textContent = sleepYearView ? '月度' : '年度';
renderSleep();
}
function renderSleepYearView(year) {
const container = document.getElementById('sleepYearContainer');
const today = new Date();
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
// 统计
let totalRecords = 0, earlyCount = 0;
sleepData.filter(r => r.date.startsWith(String(year))).forEach(r => {
totalRecords++;
if (sleepTimeToMins(r.start) <= 120) earlyCount++;
});
let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:16px;">🌙</span>
<span style="font-size:13px;font-weight:600;color:#444;">睡眠记录</span>
<span style="font-size:11px;color:#aaa;margin-left:auto;">${totalRecords}天记录 · ${earlyCount}天22点前</span>
</div>`;
html += '<div class="year-month-grid">';
for (let m = 0; m < 12; m++) {
const daysInMonth = new Date(year, m + 1, 0).getDate();
html += `<div class="year-month-block">
<div class="year-month-label">${months[m]}</div>
<div class="year-month-cells">`;
for (let d = 1; d <= daysInMonth; d++) {
const dk = `${year}-${(m+1).toString().padStart(2,'0')}-${d.toString().padStart(2,'0')}`;
const rec = sleepData.find(r => r.date === dk);
const isToday = year === today.getFullYear() && m === today.getMonth() && d === today.getDate();
const isFuture = new Date(year, m, d) > today;
let style = '';
let title = `${m+1}/${d}`;
if (rec) {
const offset = sleepTimeToMins(rec.start);
if (offset <= 120) style = 'background:#22c55e';
else if (offset <= 180) style = 'background:#86efac';
else if (offset <= 240) style = 'background:#fbbf24';
else style = 'background:#ef4444';
title += ` ${fmtSleepTime(rec.start)}`;
} else if (!isFuture) {
style = 'background:#f0f0f0';
}
const todayCls = isToday ? ' today' : '';
html += `<div class="year-cell${todayCls}" style="${style}" title="${title}"></div>`;
}
html += '</div></div>';
}
html += '</div>';
html += `<div class="year-legend">
<span><span class="year-legend-dot" style="background:#22c55e"></span>22点前</span>
<span><span class="year-legend-dot" style="background:#86efac"></span>23点前</span>
<span><span class="year-legend-dot" style="background:#fbbf24"></span>0点前</span>
<span><span class="year-legend-dot" style="background:#ef4444"></span>0点后</span>
<span><span class="year-legend-dot" style="background:#f0f0f0"></span>无记录</span>
</div>`;
container.innerHTML = html;
}
// 把入睡时间转成分钟数(以 20:00 为基准 0方便比较早晚
function sleepTimeToMins(start) {
const [h, m] = start.split(':').map(Number);
// 20:00=0, 21:00=60, 22:00=120, ..., 0:00=240, 1:00=300
let mins = (h < 12 ? h + 24 : h) * 60 + m - 20 * 60;
return mins;
}
function fmtSleepTime(start) {
const [h, m] = start.split(':').map(Number);
const period = h < 6 ? '凌晨' : h < 12 ? '上午' : h < 18 ? '下午' : '晚上';
const dispH = h > 12 ? h - 12 : h === 0 ? 12 : h;
return `${period} ${dispH}:${m.toString().padStart(2,'0')}`;
}
let buddySleepData = {}; // { name: [records] }
// 加载好友睡眠数据并更新下拉
async function loadBuddyUsers() {
try {
const resp = await fetch('/api/sleep-buddy');
const data = await resp.json();
buddySleepData = data.users || {};
const sel = document.getElementById('sleepBuddySelect');
if (!sel) return;
const current = sel.value;
const others = Object.keys(buddySleepData).filter(n => n !== 'Hera');
sel.innerHTML = '<option value="">只看我</option>' + others.map(n => `<option value="${n}" ${n===current?'selected':''}>${n}</option>`).join('');
} catch(e) {}
}
function renderSleep() {
loadBuddyUsers();
const y = sleepViewMonth.getFullYear();
const m = sleepViewMonth.getMonth();
const monthStr = `${y}${m+1}`;
document.getElementById('sleepMonthLabel').textContent = sleepYearView ? `${y}` : monthStr;
// 年度/月度视图切换
document.getElementById('sleepChartContainer').style.display = sleepYearView ? 'none' : 'block';
document.getElementById('sleepYearContainer').style.display = sleepYearView ? 'block' : 'none';
if (sleepYearView) renderSleepYearView(y);
const monthPrefix = `${y}-${(m+1).toString().padStart(2,'0')}`;
const monthRecords = sleepData.filter(r => r.date.startsWith(monthPrefix))
.sort((a,b) => a.date.localeCompare(b.date));
// 摘要:平均入睡时间、最早、最晚
const summaryEl = document.getElementById('sleepSummary');
if (monthRecords.length > 0) {
const allMins = monthRecords.map(r => sleepTimeToMins(r.start));
const avgMins = Math.round(allMins.reduce((a,b)=>a+b,0) / allMins.length);
const earliest = Math.min(...allMins);
const latest = Math.max(...allMins);
// 转回时间字符串
const minsToStr = (offset) => {
let totalMins = offset + 20 * 60;
if (totalMins >= 1440) totalMins -= 1440;
const hh = Math.floor(totalMins/60).toString().padStart(2,'0');
const mm = (totalMins%60).toString().padStart(2,'0');
return `${hh}:${mm}`;
};
// 10点前算早睡的天数
const earlyDays = allMins.filter(m => m <= 120).length; // 22:00 = 120mins offset
summaryEl.innerHTML = `
<div class="sleep-big-num">${fmtSleepTime(minsToStr(avgMins))}</div>
<div class="sleep-big-label">${monthStr} 平均入睡(${monthRecords.length}天记录)</div>
<div class="sleep-sub-stats">
<div class="sleep-sub-stat">
<div class="sleep-sub-num">${fmtSleepTime(minsToStr(earliest))}</div>
<div class="sleep-sub-label">最早</div>
</div>
<div class="sleep-sub-stat">
<div class="sleep-sub-num">${fmtSleepTime(minsToStr(latest))}</div>
<div class="sleep-sub-label">最晚</div>
</div>
<div class="sleep-sub-stat">
<div class="sleep-sub-num">${earlyDays}天</div>
<div class="sleep-sub-label">10点前入睡</div>
</div>
</div>`;
} else {
summaryEl.innerHTML = `
<div class="sleep-big-num">--</div>
<div class="sleep-big-label">${monthStr} 暂无记录</div>`;
}
drawSleepChart(y, m, monthRecords);
// 表格
const tbody = document.getElementById('sleepTableBody');
const hint = document.getElementById('sleepEmptyHint');
if (monthRecords.length === 0) {
tbody.innerHTML = '';
hint.style.display = 'block';
return;
}
hint.style.display = 'none';
const displayRecords = monthRecords.slice().reverse();
tbody.innerHTML = '';
displayRecords.forEach(r => {
const dayName = ['周日','周一','周二','周三','周四','周五','周六'][new Date(r.date).getDay()];
const mins = sleepTimeToMins(r.start);
// 颜色22:00前绿色23:00前黄色之后红色
const color = mins <= 120 ? '#22c55e' : mins <= 180 ? '#f59e0b' : '#ef4444';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.date.slice(5)} ${dayName}</td>
<td style="color:${color};font-weight:500">${fmtSleepTime(r.start)}</td>
<td><button class="sleep-del-btn" onclick="deleteSleepRecord('${r.date}')">×</button></td>`;
tbody.appendChild(tr);
});
}
function drawSleepChart(year, month, records) {
const canvas = document.getElementById('sleepChart');
const dpr = window.devicePixelRatio || 1;
const 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;
const H = rect.height;
ctx.clearRect(0, 0, W, H);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const padL = 50, padR = 16, padT = 20, padB = 36;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
// Y轴21:00顶部=好)到 02:00底部=晚)
// 用分钟偏移量21:00=0, 22:00=60, 23:00=120, 0:00=180, 1:00=240, 2:00=300
const yMin = 0; // 21:00
const yMax = 300; // 02:00
const yScale = chartH / (yMax - yMin);
// 网格线和时间标签
const timeLabels = [
{ offset: 0, label: '21:00' },
{ offset: 60, label: '22:00' },
{ offset: 120, label: '23:00' },
{ offset: 180, label: '0:00' },
{ offset: 240, label: '1:00' },
{ offset: 300, label: '2:00' },
];
ctx.strokeStyle = '#f0f0f0';
ctx.lineWidth = 1;
ctx.fillStyle = '#bbb';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'right';
timeLabels.forEach(tl => {
const y = padT + (tl.offset - yMin) * yScale;
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(W - padR, y);
ctx.stroke();
ctx.fillText(tl.label, padL - 6, y + 4);
});
// 目标参考线(使用用户设置的目标时间)
const _tgt = plannerSleepTargetTime || '22:00';
const _tgtMins = sleepTimeToMins(_tgt); // offset from 20:00
const refY = padT + (_tgtMins - 60) * yScale;
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(padL, refY);
ctx.lineTo(W - padR, refY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#22c55e';
ctx.textAlign = 'left';
ctx.fillText(`${_tgt} 目标`, W - padR - 56, refY - 5);
// 数据点
const dataMap = {};
records.forEach(r => {
const day = parseInt(r.date.slice(8));
dataMap[day] = sleepTimeToMins(r.start);
});
const today = new Date();
const todayDay = (today.getFullYear() === year && today.getMonth() === month) ? today.getDate() : daysInMonth;
// 为缺失天预估用前3天平均值
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;
}
ctx.textAlign = 'center';
const allPoints = []; // { x, y, day, offset, real, color }
for (let d = 1; d <= daysInMonth; d++) {
const x = padL + (d - 0.5) / daysInMonth * chartW;
if (d % 5 === 1 || d === daysInMonth) {
ctx.fillStyle = '#bbb';
ctx.font = '10px -apple-system, sans-serif';
ctx.fillText(`${d}`, x, H - padB + 16);
}
if (d > todayDay) continue; // 未来的天不画
let offsetVal, isReal = true;
if (dataMap[d] !== undefined) {
offsetVal = dataMap[d];
} else {
offsetVal = estimateForDay(d);
isReal = false;
}
if (offsetVal === null) continue;
const offset = offsetVal - 60;
const clampedOffset = Math.max(0, Math.min(offset, yMax));
const y = padT + clampedOffset * yScale;
const color = offset <= 60 ? '#22c55e' : offset <= 120 ? '#f59e0b' : '#ef4444';
allPoints.push({ x, y, day: d, offset: offsetVal, real: isReal, color });
}
// 折线(只连实际数据点)
const realPoints = allPoints.filter(p => p.real);
if (realPoints.length > 1) {
ctx.strokeStyle = '#667eea';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
realPoints.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
}
// 圆点
allPoints.forEach(p => {
if (p.real) {
ctx.fillStyle = '#fff';
ctx.strokeStyle = p.color;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(p.x, p.y, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
// 预估点:空心虚线圆
ctx.strokeStyle = '#ccc';
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([]);
}
});
// Tooltip鼠标/触摸时显示
const minsToTimeStr = (offset) => {
let totalMins = offset + 20 * 60;
if (totalMins >= 1440) totalMins -= 1440;
const h = Math.floor(totalMins/60);
const m = totalMins % 60;
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
};
// 存储点数据供tooltip使用
// 好友数据叠加(如果选了)
const buddyName = document.getElementById('sleepBuddySelect')?.value;
if (buddyName && buddySleepData[buddyName]) {
const buddyRecs = buddySleepData[buddyName];
const buddyPts = [];
for (let d = 1; d <= daysInMonth; d++) {
const dk = `${year}-${(month+1).toString().padStart(2,'0')}-${d.toString().padStart(2,'0')}`;
const rec = buddyRecs.find(r => r.date === dk);
if (rec) {
const x = padL + (d - 0.5) / daysInMonth * chartW;
const off = sleepTimeToMins(rec.start) - 60;
const cy = padT + Math.max(0, Math.min(off, yMax)) * yScale;
buddyPts.push({ x, y: cy });
}
}
if (buddyPts.length > 1) {
ctx.strokeStyle = '#f472b6';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.lineJoin = 'round';
ctx.beginPath();
buddyPts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
ctx.stroke();
ctx.setLineDash([]);
}
buddyPts.forEach(p => {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#f472b6';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
});
// 图例
ctx.fillStyle = '#f472b6';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${buddyName}`, W - padR, padT - 4);
ctx.fillStyle = '#667eea';
ctx.fillText('— 我', W - padR - ctx.measureText(`${buddyName}`).width - 16, padT - 4);
}
canvas._chartPoints = allPoints;
canvas._chartParams = { padL, padT, padB, chartW, daysInMonth, year, month, minsToTimeStr };
if (!canvas._tooltipBound) {
canvas._tooltipBound = true;
const showTip = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const mx = clientX - rect.left;
const pts = canvas._chartPoints || [];
let closest = null, minDist = 30;
pts.forEach(p => {
const dist = Math.abs(p.x - mx);
if (dist < minDist) { minDist = dist; closest = p; }
});
let tip = document.getElementById('sleepChartTip');
if (!tip) {
tip = document.createElement('div');
tip.id = 'sleepChartTip';
tip.style.cssText = 'position:absolute;background:rgba(0,0,0,0.8);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 params = canvas._chartParams;
const timeStr = params.minsToTimeStr(closest.offset);
const label = closest.real ? timeStr : `~${timeStr}(预估)`;
tip.textContent = `${params.month+1}/${closest.day} ${label}`;
tip.style.left = closest.x + 'px';
tip.style.top = (closest.y - 30) + '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 tip = document.getElementById('sleepChartTip');
if (tip) tip.style.opacity = '0';
});
}
}
// ============================================================
// 7. 通用提醒系统 + 自定义提醒
// ============================================================
// 解析 "5点提醒我xxx" / "3月27号8点提醒我xxx" / "明天9点提醒我xxx" / "下午3点提醒xxx"
function parseCustomReminder(text) {
// 必须包含"提醒"
if (!/提醒/.test(text)) return null;
// 周期性提醒:"每X个月/天/周提醒我XXX"
// 中文数字转阿拉伯
const _cn2n = {'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'半':0.5};
const periodicMatch = text.match(/每([一二两三四五六七八九十\d]+)个?(月|天|日|周).*提醒我?(.+)/);
if (periodicMatch) {
const raw = periodicMatch[1];
const interval = _cn2n[raw] || parseInt(raw) || 1;
const unit = periodicMatch[2];
let content = periodicMatch[3].replace(/^[,\s]+/, '').trim();
const now = new Date();
const next = new Date(now);
if (unit === '月') next.setMonth(next.getMonth() + interval);
else if (unit === '周') next.setDate(next.getDate() + interval * 7);
else next.setDate(next.getDate() + interval);
return {
id: 'custom_' + Date.now(),
date: dateKey(next),
time: '09:00',
hour: 9, minute: 0,
content,
timeLabel: `${interval}${unit}(下次:${next.getMonth()+1}/${next.getDate()}`,
dismissed: false,
recurring: { interval, unit },
};
}
const now = new Date();
let year = now.getFullYear();
let month = now.getMonth() + 1;
let day = now.getDate();
let hour = -1, minute = 0;
let content = text;
// 提取日期3月27号 / 3.27 / 明天 / 后天
if (/明天/.test(text)) {
const d = new Date(now); d.setDate(d.getDate() + 1);
month = d.getMonth() + 1; day = d.getDate(); year = d.getFullYear();
} else if (/后天/.test(text)) {
const d = new Date(now); d.setDate(d.getDate() + 2);
month = d.getMonth() + 1; day = d.getDate(); year = d.getFullYear();
} else {
const dateMatch = text.match(/(\d{1,2})月(\d{1,2})[号日]/) || text.match(/(\d{1,2})[\.\/](\d{1,2})/);
if (dateMatch) {
month = parseInt(dateMatch[1]);
day = parseInt(dateMatch[2]);
if (month < now.getMonth() + 1) year++; // 如果月份已过,认为是明年
}
}
// 提取时间8点 / 8点半 / 8:30 / 下午3点 / 上午10点
const isPM = /下午|晚上|傍晚/.test(text);
const isAM = /上午|早上|早/.test(text);
const timeMatch = text.match(/(\d{1,2})[点:](\d{1,2})?/) ;
if (timeMatch) {
hour = parseInt(timeMatch[1]);
minute = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
if (text.includes('半') && minute === 0) minute = 30;
if (isPM && hour < 12) hour += 12;
if (isAM && hour === 12) hour = 0;
// 如果没有明确上下午1-6点默认下午
if (!isPM && !isAM && hour >= 1 && hour <= 6) hour += 12;
}
// 相对时间X小时后 / X分钟后 / 半小时后
if (hour < 0) {
const relMatch = text.match(/(\d+)\s*小时后/) || text.match(/(\d+)\s*个小时后/);
const relMinMatch = text.match(/(\d+)\s*分钟后/);
const halfHour = /半小时后|半个小时后/.test(text);
if (relMatch || relMinMatch || halfHour) {
const target = new Date(now);
if (relMatch) target.setHours(target.getHours() + parseInt(relMatch[1]));
else if (halfHour) target.setMinutes(target.getMinutes() + 30);
else if (relMinMatch) target.setMinutes(target.getMinutes() + parseInt(relMinMatch[1]));
hour = target.getHours();
minute = target.getMinutes();
// 取整到5分钟
minute = Math.round(minute / 5) * 5;
if (minute === 60) { minute = 0; hour++; }
if (hour >= 24) { hour -= 24; day++; }
year = target.getFullYear();
month = target.getMonth() + 1;
day = target.getDate();
}
}
if (hour < 0) return null;
// 提取提醒内容:取"提醒我"后面的部分,去掉时间描述
const contentMatch = text.match(/提醒我?(.+)/);
if (contentMatch) {
content = contentMatch[1]
.replace(/\d+\s*(小时|个小时|分钟)后/g, '')
.replace(/半小时后|半个小时后/g, '')
.replace(/^[,\s]+/, '').trim();
}
const targetDate = `${year}-${month.toString().padStart(2,'0')}-${day.toString().padStart(2,'0')}`;
const targetTime = `${hour.toString().padStart(2,'0')}:${minute.toString().padStart(2,'0')}`;
const timeLabel = targetDate === dateKey(now)
? `今天${targetTime}`
: `${month}${day}${targetTime}`;
return {
id: 'custom_' + Date.now(),
date: targetDate,
time: targetTime,
hour, minute,
content,
timeLabel,
dismissed: false,
};
}
// 检查自定义提醒
function checkCustomReminders() {
const reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
const now = new Date();
const todayStr = dateKey(now);
const nowMins = now.getHours() * 60 + now.getMinutes();
// 调试:在同步状态旁显示提醒检查状态
const _dbg = document.getElementById('syncStatus');
const pending = reminders.filter(r => !r.dismissed && r.date === todayStr);
if (_dbg && pending.length) {
const next = pending.find(r => r.hour * 60 + r.minute >= nowMins - 30);
_dbg.textContent = `${pending.length}${next ? next.time : ''}`;
}
for (const r of reminders) {
if (r.dismissed) continue;
if (r.date !== todayStr) continue;
const targetMins = r.hour * 60 + r.minute;
const diff = nowMins - targetMins;
// 临时调试
if (_dbg) _dbg.textContent = `${r.time} diff=${diff}`;
if (diff >= 0 && diff < 30) {
// 用独立的弹窗,不和固定提醒共享 UI
let popup = document.getElementById('customReminderPopup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'customReminderPopup';
popup.style.cssText = 'display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;';
document.body.appendChild(popup);
}
popup.innerHTML = `<div style="background:white;border-radius:16px;padding:24px;width:300px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,0.2);">
<div style="font-size:36px;margin-bottom:12px;">⏰</div>
<div style="font-size:16px;font-weight:600;color:#333;margin-bottom:8px;">提醒时间到</div>
<div style="font-size:14px;color:#666;margin-bottom:20px;">${r.content}</div>
<button onclick="dismissCustomReminder('${r.id}');document.getElementById('customReminderPopup').style.display='none';" style="padding:10px 24px;background:#667eea;color:white;border:none;border-radius:10px;font-size:14px;cursor:pointer;">知道了 ✨</button>
</div>`;
popup.style.display = 'flex';
// 振动
if (navigator.vibrate) navigator.vibrate([200, 100, 200, 100, 200]);
// 系统通知
sendSystemNotification('提醒时间到', r.content, r.id);
return true;
}
}
return false;
}
function dismissCustomReminder(id) {
const reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
const r = reminders.find(x => x.id === id);
if (r) {
r.dismissed = true;
// 周期提醒:自动创建下一次
if (r.recurring) {
const next = new Date(r.date + 'T00:00:00');
if (r.recurring.unit === '月') next.setMonth(next.getMonth() + r.recurring.interval);
else if (r.recurring.unit === '周') next.setDate(next.getDate() + r.recurring.interval * 7);
else next.setDate(next.getDate() + r.recurring.interval);
reminders.push({
id: 'custom_' + Date.now(),
date: dateKey(next),
time: r.time || '09:00',
hour: r.hour || 9, minute: r.minute || 0,
content: r.content,
timeLabel: `${r.recurring.interval}${r.recurring.unit}(下次:${next.getMonth()+1}/${next.getDate()}`,
dismissed: false,
recurring: r.recurring,
});
}
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders)); pushNow();
}
document.getElementById('sleepReminder').classList.remove('show');
}
// 清理过期的自定义提醒超过7天的自动删除
function cleanOldReminders() {
let reminders = JSON.parse(localStorage.getItem('sp_custom_reminders')) || [];
const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7);
const before = reminders.length;
reminders = reminders.filter(r => r.date >= dateKey(cutoff));
localStorage.setItem('sp_custom_reminders', JSON.stringify(reminders));
if (reminders.length !== before) markLocalDirty();
}
cleanOldReminders();
function getFixedReminderTime(id, defaultH, defaultM) {
const saved = JSON.parse(localStorage.getItem('sp_fixed_reminders') || '[]');
const s = saved.find(r => r.id === id);
if (s?.time) { const [h,m] = s.time.split(':').map(Number); return {h,m}; }
return { h: defaultH, m: defaultM };
}
const _mt = getFixedReminderTime('morning', 9, 10);
const _ht = getFixedReminderTime('health', 18, 0);
const _et = getFixedReminderTime('evening', 20, 30);
const _st = getFixedReminderTime('sleep', 21, 30);
const REMINDERS = [
{
id: 'morning',
hour: _mt.h, minute: _mt.m,
icon: '💆',
messages: [
{ title:'早安,头疗时间到', msg:'先做头疗再吃药,给自己一个清爽的早晨' },
{ title:'别忘了头疗和吃药哦', msg:'照顾好自己,才能照顾好一切' },
{ title:'健康提醒', msg:'头疗 + 吃药,两件小事,坚持就是大事' },
{ title:'早上好 Hera', msg:'头疗做了吗?药吃了吗?今天也要元气满满' },
{ title:'每天的小仪式', msg:'头疗和吃药是对自己最好的投资,现在就开始吧' },
],
dismissText: '已完成 ✨',
},
{
id: 'health',
hour: _ht.h, minute: _ht.m,
icon: '💊',
messages: [], // 动态生成
dismissText: '知道了 ✨',
dynamic: true,
},
{
id: 'evening',
hour: _et.h, minute: _et.m,
icon: '📋',
messages: [],
dismissText: '知道了 ✨',
dynamic: true,
},
{
id: 'sleep',
hour: _st.h, minute: _st.m,
icon: '🌙',
messages: [
{ title:'该准备休息啦', msg:'今天也辛苦了,早点睡才能元气满满地迎接明天' },
{ title:'晚安时间到了', msg:'你今天已经做得很棒了,把剩下的事交给明天的自己吧' },
{ title:'月亮出来啦', msg:'好好的睡眠是最好的护肤品,比任何精华都有效哦' },
{ title:'该充电了', msg:'你就像手机一样,电量低了就要充满,明天才能继续发光' },
{ title:'温柔提醒', msg:'早睡的人运气不会太差,今晚试试 10 点半上床?' },
{ title:'抱抱自己', msg:'不管今天完成了多少,你都值得一个好觉。去休息吧' },
{ title:'悄悄告诉你', msg:'坚持早睡 21 天就能养成习惯,你已经在路上了' },
{ title:'深呼吸', msg:'放下手机,泡杯花草茶,让身体慢慢切换到休息模式' },
{ title:'晚安小Hera', msg:'明天还有好多好玩的事情等着你,现在先好好休息' },
{ title:'你知道吗', msg:'睡前 30 分钟放下屏幕,睡眠质量能提升 40% 哦' },
],
dismissText: '记下了,去睡觉 ✨',
},
];
// 每条提醒独立记录 dismiss 状态sp_dismissed_sleep=2026-03-26
function isDismissed(id) {
return localStorage.getItem('sp_dismissed_' + id) === dateKey(new Date());
}
function dismissReminder(id) {
localStorage.setItem('sp_dismissed_' + id, dateKey(new Date()));
document.getElementById('sleepReminder').classList.remove('show');
pushNow();
}
function showReminder(r) {
let msg;
if (r.dynamic && (r.id === 'health' || r.id === 'evening')) {
// 每日未完成汇总
const lines = [];
// 健康打卡
const uncheckedHealth = getUncheckedHealthItems();
if (uncheckedHealth.length > 0) lines.push('💊 ' + uncheckedHealth.map(i => i.name).join('、'));
// 音乐打卡
const mPlan = musicPlans[getMonthKey(new Date())] || [];
const mChk = musicChecks[dateKey(new Date())] || {};
const uncheckedMusic = mPlan.map(id => musicItems.find(i => i.id === id)).filter(i => i && !mChk[i.id]);
if (uncheckedMusic.length > 0) lines.push('🎵 ' + uncheckedMusic.map(i => i.name).join('、'));
// 紧急待办
const urgent = (todos.q1 || []).filter(t => !t.done);
if (urgent.length > 0) lines.push('❗ ' + urgent.map(t => t.text.slice(0,12)).join('、'));
// 过期待办
const tdy = dateKey(new Date());
const overdue = [...(todos.q1||[]),...(todos.q2||[]),...(todos.q3||[]),...(todos.q4||[])].filter(t => !t.done && t.deadline && t.deadline < tdy);
if (overdue.length > 0) lines.push('⚠️ 过期' + overdue.length + '条');
// 收集箱
// 目标未完成
const goalUnchecked = getUncheckedGoalTasks();
if (goalUnchecked.length > 0) lines.push('🎯 目标' + goalUnchecked.length + '项未完成');
if (inbox.length > 0) lines.push('📥 收集箱' + inbox.length + '条未分类');
if (lines.length === 0) return;
msg = { title: '今日未完成汇总', msg: lines.join('\n') };
} else {
const dayNum = new Date().getDate() + new Date().getMonth() * 31;
msg = r.messages[dayNum % r.messages.length];
}
const el = document.getElementById('sleepReminder');
document.getElementById('reminderIcon').textContent = r.icon;
document.getElementById('reminderTitle').textContent = msg.title;
document.getElementById('reminderMsg').textContent = msg.msg;
el.querySelector('.reminder-dismiss').textContent = r.dismissText;
let dismissFn = r.id.startsWith('custom_') ? `dismissCustomReminder('${r.id}')` : `dismissReminder('${r.id}')`;
if (r.onDismiss) dismissFn += `;${r.onDismiss}`;
el.querySelector('.reminder-dismiss').setAttribute('onclick', dismissFn);
el.classList.add('show');
// 手机振动
if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
// 系统通知(页面在后台也能弹出)
sendSystemNotification(msg.title, msg.msg, r.id);
}
function checkReminders() {
const now = new Date();
const h = now.getHours();
const m = now.getMinutes();
let activeReminder = null;
for (const r of REMINDERS) {
// 提醒触发窗口:从设定时间起 30 分钟内
const startMins = r.hour * 60 + r.minute;
const nowMins = h * 60 + m;
let diff = nowMins - startMins;
// 睡眠提醒跨午夜特殊处理
if (r.id === 'sleep' && diff < 0) diff += 1440;
const inWindow = diff >= 0 && diff < 30;
// 睡眠提醒扩展到凌晨
const isSleepLate = r.id === 'sleep' && (h >= 22 || (h >= 0 && h < 6));
if ((inWindow || isSleepLate) && !isDismissed(r.id)) {
activeReminder = r;
break; // 优先显示第一个匹配的
}
}
// 自定义提醒已独立运行,不在这里检查
const el = document.getElementById('sleepReminder');
if (activeReminder) {
showReminder(activeReminder);
} else {
// 检查滞留任务前三个象限中超过3天未完成的
const staleChecked = checkStaleTasks(h, m);
if (!staleChecked) {
const weeklyChecked = checkWeeklyReview();
if (!weeklyChecked) {
checkMonthlyReport();
}
}
}
}
function checkMonthlyReport() {
const now = new Date();
// 每月1号 9:00-10:00 弹报表
if (now.getDate() !== 1) return false;
const h = now.getHours();
if (h < 9 || h >= 10) return false;
if (isDismissed('monthly_report')) return false;
const report = generateMonthlyReport();
if (!report) return false;
showReminder({
id: 'monthly_report',
icon: '📊',
messages: [{ title: report.title, msg: report.msg }],
dismissText: '太棒了!继续加油 💪',
});
return true;
}
function generateMonthlyReport() {
const now = new Date();
// 上个月
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 2, 1);
const lmKey = getMonthKey(lastMonth);
const pmKey = getMonthKey(prevMonth);
const lmName = `${lastMonth.getFullYear()}${lastMonth.getMonth()+1}`;
const lmDays = new Date(lastMonth.getFullYear(), lastMonth.getMonth()+1, 0).getDate();
const lines = [];
const encouragements = [];
// === 睡眠统计 ===
const lmPrefix = lmKey;
const pmPrefix = pmKey;
const lmSleep = sleepData.filter(r => r.date.startsWith(lmPrefix));
const pmSleep = sleepData.filter(r => r.date.startsWith(pmPrefix));
if (lmSleep.length > 0) {
const avgMins = Math.round(lmSleep.map(r => sleepTimeToMins(r.start)).reduce((a,b)=>a+b,0) / lmSleep.length);
const earlyDays = lmSleep.filter(r => sleepTimeToMins(r.start) <= 120).length;
const minsToStr = (offset) => {
let t = offset + 20*60; if(t>=1440) t-=1440;
return `${Math.floor(t/60)}:${(t%60).toString().padStart(2,'0')}`;
};
let sleepLine = `🌙 睡眠:记录${lmSleep.length}天,平均${minsToStr(avgMins)}入睡,${earlyDays}天22点前`;
if (pmSleep.length > 0) {
const pmEarly = pmSleep.filter(r => sleepTimeToMins(r.start) <= 120).length;
const diff = earlyDays - pmEarly;
if (diff > 0) { sleepLine += ` 📈+${diff}`; encouragements.push('早睡天数在进步!'); }
else if (diff < 0) sleepLine += ` 📉${diff}`;
}
lines.push(sleepLine);
}
// === 健康打卡 ===
const lmHealthPlan = healthPlans[lmKey] || [];
if (lmHealthPlan.length > 0) {
let totalDone = 0, totalPossible = 0;
for (let d = 1; d <= lmDays; d++) {
const dk = `${lmKey}-${d.toString().padStart(2,'0')}`;
lmHealthPlan.forEach(id => {
totalPossible++;
if (healthChecks[dk]?.[id]) totalDone++;
});
}
const rate = Math.round(totalDone/totalPossible*100);
lines.push(`💊 健康:${rate}% 完成率(${totalDone}/${totalPossible}`);
if (rate >= 80) encouragements.push('健康打卡超棒!');
}
// === 音乐打卡 ===
const lmMusicPlan = musicPlans[lmKey] || [];
if (lmMusicPlan.length > 0) {
let totalDone = 0, totalPossible = 0;
for (let d = 1; d <= lmDays; d++) {
const dk = `${lmKey}-${d.toString().padStart(2,'0')}`;
lmMusicPlan.forEach(id => {
totalPossible++;
if (musicChecks[dk]?.[id]) totalDone++;
});
}
const rate = Math.round(totalDone/totalPossible*100);
lines.push(`🎵 音乐:${rate}% 完成率(${totalDone}/${totalPossible}`);
if (rate >= 50) encouragements.push('坚持练习很了不起!');
}
// === 待办完成 ===
const allTodos = [...(todos.q1||[]),...(todos.q2||[]),...(todos.q3||[]),...(todos.q4||[])];
const doneTodos = allTodos.filter(t => t.done).length;
if (allTodos.length > 0) {
lines.push(`✅ 待办:${doneTodos}/${allTodos.length} 已完成`);
}
// === 读书 ===
const readDoc = personalDocs.find(d => d.id === 'doc_reading');
if (readDoc) {
const lmBooks = readDoc.entries.filter(e => e.status === '已读' && e.date && e.date.startsWith(lmKey));
if (lmBooks.length > 0) {
lines.push(`📖 读书:读完了 ${lmBooks.map(b=>b.extracted||b.text).join('、')}`);
encouragements.push('读书使人充实!');
}
}
if (lines.length === 0) return null;
// 鼓励语
const defaultEnc = ['新的一个月,继续保持!','每一天的坚持都在让你变得更好','你比自己想象的更优秀'];
const enc = encouragements.length > 0 ? encouragements : [defaultEnc[now.getMonth() % defaultEnc.length]];
return {
title: `📊 ${lmName} 月度报告`,
msg: lines.join('\n') + '\n\n✨ ' + enc.join(' '),
};
}
function checkWeeklyReview() {
const now = new Date();
const dayOfWeek = now.getDay(); // 0=周日, 6=周六
if (dayOfWeek !== 0 && dayOfWeek !== 6) return false; // 只在周末
const h = now.getHours();
if (h < 21 || h >= 22) return false; // 只在21:00-22:00
if (isDismissed('weekly_review')) return false;
const msgs = [
'这一周辛苦了!花几分钟回顾一下,看看哪些做得好,哪些可以改进',
'周末愉快!趁现在总结一下本周,为下周做好准备',
'又过了一周,回头看看自己的成长,你比想象中做得更好',
];
showReminder({
id: 'weekly_review',
icon: '📝',
messages: [{ title: '本周回顾时间到', msg: msgs[now.getDate() % msgs.length] }],
dismissText: '去回顾 →',
onDismiss: "document.querySelector('[data-tab=\"review\"]')?.click()",
});
return true;
}
function checkStaleTasks(h, m) {
// 每天 10:00, 14:00, 19:00 检查
const slots = [10, 14, 19];
const nowMins = h * 60 + m;
let inSlot = false;
for (const sh of slots) {
const diff = nowMins - sh * 60;
if (diff >= 0 && diff < 15) { inSlot = true; break; }
}
if (!inSlot) return false;
if (isDismissed('stale_' + h)) return false;
// 找前三个象限中超过3天未完成的任务
const today = new Date();
const threeDaysAgo = new Date(today); threeDaysAgo.setDate(today.getDate() - 3);
const cutoff = dateKey(threeDaysAgo);
const staleTasks = [];
['q1','q2','q3'].forEach(qk => {
(todos[qk] || []).forEach(t => {
if (!t.done && t.id) {
// 用 idtimestamp判断创建时间
const created = new Date(t.id);
if (created < threeDaysAgo) {
const days = Math.floor((today - created) / 86400000);
staleTasks.push({ text: t.text.slice(0, 15), days });
}
}
});
});
if (staleTasks.length === 0) return false;
const lines = staleTasks.slice(0, 5).map(t => `${t.text}${t.days}天)`).join('\n');
showReminder({
id: 'stale_' + h,
icon: '🔥',
messages: [{
title: `${staleTasks.length} 条任务已拖延超过3天`,
msg: lines + (staleTasks.length > 5 ? `\n…还有 ${staleTasks.length-5}` : ''),
}],
dismissText: '我知道了,马上去做',
});
return true;
}
// 测试提醒(开发用,在控制台调用 testReminder('sleep') 或 testReminder('morning')
function testReminder(id) {
if (id === 'weekly_review') {
showReminder({
id: 'weekly_review', icon: '📝',
messages: [{ title: '本周回顾时间到', msg: '这一周辛苦了!花几分钟回顾一下' }],
dismissText: '去回顾 →',
onDismiss: "document.querySelector('[data-tab=\"review\"]')?.click()",
});
return;
}
const r = REMINDERS.find(r => r.id === id);
if (r) showReminder(r);
}
// 请求通知权限 + 注册 Service Worker支持后台通知
// iOS PWA 要求通知权限必须由用户手势触发,自动调用会被忽略
// 在首次点击页面任意位置时请求权限
if ('Notification' in window && Notification.permission === 'default') {
const _askNotif = () => {
Notification.requestPermission().then(p => {
if (p === 'granted') showToast('通知已开启');
});
document.removeEventListener('click', _askNotif);
};
document.addEventListener('click', _askNotif, { once: true });
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// 通过 SW 发送系统通知(即使页面在后台也能弹出)
function sendSystemNotification(title, body, tag) {
if (Notification.permission !== 'granted') return;
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'SHOW_NOTIFICATION', title, body, tag });
} else {
// fallback: 直接用 Notification API
new Notification(title, { body, icon: 'icon-180.png', requireInteraction: true });
}
}
// 每 30 秒检查一次
checkReminders();
setInterval(checkReminders, 30000);
// 自定义提醒独立检查每15秒一次不受固定提醒影响
setInterval(checkCustomReminders, 15000);
checkCustomReminders();
// 检查睡眠好友通知(在 Planner 里显示)
async function checkBuddyNotifs() {
try {
const resp = await fetch('/api/sleep-buddy', { method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ user:'Hera', action:'get-notifications' })});
const data = await resp.json();
const lastSeen = parseFloat(localStorage.getItem('sp_buddy_notif_seen')||'0');
const news = (data.notifications||[]).filter(n => n.ts > lastSeen);
if (news.length > 0) {
localStorage.setItem('sp_buddy_notif_seen', String(Math.max(...news.map(n=>n.ts))));
showReminder({
id: 'buddy_sleep',
icon: '🌙',
messages: [{ title: news[0].msg, msg: '你也早点休息吧!' }],
dismissText: '知道了 💤',
});
}
} catch(e) {}
}
checkBuddyNotifs();
setInterval(checkBuddyNotifs, 60000);
// 每 5 秒检查同步状态
let syncCounter = 0;
setInterval(async () => {
if (!isLoggedIn() || !syncReady || syncBusy) return;
if (localDirty) {
// markLocalDirty 的 2 秒 timer 负责推送,这里只做兜底
// 如果 2 秒 timer 因为 syncBusy 跳过了,这里兜住
clearTimeout(_dirtyTimer);
localDirty = false;
const ok = await pushToServer();
if (!ok) localDirty = true; // 失败时重新标记
} else {
// 每 60 秒拉一次(每 12 个周期)
syncCounter++;
if (syncCounter >= 12) {
syncCounter = 0;
syncBusy = true; // 拉取时也锁住,防止和推送冲突
try {
const loaded = await loadFromServer();
if (loaded) { reloadAllData(); refreshCurrentTab(); }
} finally { syncBusy = false; }
}
}
}, 5000);
// ============================================================
// 数据导入(一次性)
// ============================================================
if (!localStorage.getItem('sp_import_reading_v1')) {
const readingDoc = personalDocs.find(d => d.id === 'doc_reading');
if (readingDoc) {
const importData = [
// ===== 已读 =====
{ date:'2026-03-25', extracted:'重返狼群', text:'重返狼群', status:'已读' },
{ date:'2026-03-24', extracted:'人类群星闪耀时', text:'人类群星闪耀时', status:'已读' },
{ date:'2026-03-16', extracted:'像马一样思考', text:'像马一样思考', status:'已读' },
{ date:'2026-03-06', extracted:'哲学的慰藉', text:'再次听完了哲学的慰藉', status:'已读' },
{ date:'2026-02-23', extracted:'哲学家们都干了什么', text:'听完了哲学家们都干了什么——需要再听一遍', status:'已读' },
{ date:'2026-02-13', extracted:'你一生的故事', text:'你一生的故事', status:'已读' },
{ date:'2026-02-08', extracted:'控糖革命', text:'控糖革命', status:'已读' },
{ date:'2026-02-06', extracted:'重返狼群背后的故事', text:'重返狼群背后的故事', status:'已读' },
{ date:'2026-01-19', extracted:'活出生命的意义', text:'活出生命的意义', status:'已读' },
{ date:'2025-12-06', extracted:'消失的多巴胺', text:'消失的多巴胺', status:'已读' },
{ date:'2025-11-13', extracted:'人体简史', text:'人体简史', status:'已读' },
{ date:'2025-08-27', extracted:'数学的雨伞下', text:'数学的雨伞下', status:'已读' },
{ date:'2025-08-10', extracted:'排毒吧大脑', text:'排毒吧大脑', status:'已读' },
{ date:'2025-07-18', extracted:'你是你吃出来的', text:'你是你吃出来的——需要重新做一遍笔记', status:'已读' },
{ date:'2025-06-26', extracted:'青春期的大脑', text:'听完了青春期的大脑——需要再看一遍', status:'已读' },
{ date:'2025-06-11', extracted:'养育男孩', text:'养育男孩', status:'已读' },
{ date:'2025-05-17', extracted:'小狗钱钱', text:'听完了小狗钱钱', status:'已读' },
{ date:'2025-05-15', extracted:'智人之上', text:'听完了智人之上', status:'已读' },
{ date:'2025-04-13', extracted:'特斯拉的秘密宏图', text:'特斯拉的秘密宏图', status:'已读' },
{ date:'2025-02-20', extracted:'金钱心理学', text:'听完了金钱心理学', status:'已读' },
{ date:'2025-01-14', extracted:'思考,快与慢', text:'思考,快与慢,三分之一,实在看不下去', status:'已读' },
{ date:'2024-12-05', extracted:'这样爱你刚刚好,我的三年级孩子', text:'这样爱你刚刚好,我的三年级孩子', status:'已读' },
{ date:'2024-11-29', extracted:'财务自由笔记', text:'财务自由笔记', status:'已读' },
{ date:'2024-11-14', extracted:'改变提问,改变人生', text:'改变提问,改变人生', status:'已读' },
{ date:'2024-08-11', extracted:'学点法律,避点坑', text:'学点法律,避点坑:有趣有料的法律科普', status:'已读' },
{ date:'2024-08-03', extracted:'证言 使女的故事续', text:'证言 使女的故事续', status:'已读' },
{ date:'2024-08-03', extracted:'使女的故事', text:'使女的故事', status:'已读' },
{ date:'2024-07-30', extracted:'越过内心那座山', text:'越过内心那座山', status:'已读' },
{ date:'2024-07-05', extracted:'简明日本史', text:'简明日本史', status:'已读' },
{ date:'2024-06-15', extracted:'Mindfulness', text:'Mindfulness: a practical guide to finding peace in a frantic world', status:'已读' },
{ date:'2024-05-22', extracted:'汤姆叔叔的小屋', text:'汤姆叔叔的小屋', status:'已读' },
{ date:'2024-04-08', extracted:'全脑教养法', text:'全脑教养法', status:'已读' },
{ date:'2024-04-02', extracted:'逛动物园是件正经事', text:'逛动物园是件正经事', status:'已读' },
{ date:'2024-02-16', extracted:'茅台传', text:'茅台传', status:'已读' },
{ date:'2024-01-26', extracted:'被讨厌的勇气', text:'被讨厌的勇气', status:'已读' },
{ date:'2024-01-26', extracted:'半小时漫画科学史2/3等', text:'半小时漫画科学史2/3宇宙大爆炸青春期生理篇心理篇预防常见病', status:'已读' },
{ date:'2024-01-11', extracted:'半小时漫画科学史1', text:'半小时漫画科学史1', status:'已读' },
{ date:'2024-01-10', extracted:'牙医谋杀案', text:'牙医谋杀案', status:'已读' },
{ date:'2024-01-07', extracted:'恶意', text:'恶意', status:'已读' },
{ date:'2024-01-06', extracted:'猫、爱因斯坦和密码学', text:'猫、爱因斯坦和密码学', status:'已读' },
{ date:'2023-12-21', extracted:'白金数据', text:'白金数据', status:'已读' },
{ date:'2023-12-04', extracted:'这样爱你刚刚好,我的二年级孩子', text:'这样爱你刚刚好,我的二年级孩子', status:'已读' },
{ date:'2023-12-03', extracted:'抗炎', text:'抗炎 从根源上逆转慢病的炎症消除方案', status:'已读' },
{ date:'2023-11-09', extracted:'滚雪球1', text:'滚雪球1', status:'已读' },
{ date:'2023-10-19', extracted:'游戏力3', text:'游戏力3借给了邓薇', status:'已读' },
{ date:'2023-10-13', extracted:'游戏力2', text:'游戏力2', status:'已读' },
{ date:'2023-10-05', extracted:'纳瓦尔宝典', text:'纳瓦尔宝典', status:'已读' },
{ date:'2023-06-26', extracted:'游戏力1', text:'游戏力1', status:'已读' },
{ date:'2023-01-25', extracted:'Big Ideas for Curious Minds', text:'Big Ideas for Curious Minds', status:'已读' },
{ date:'2022-10-02', extracted:'植物的战斗', text:'植物的战斗', status:'已读' },
{ date:'2022-07-29', extracted:'人体简史', text:'人体简史', status:'已读' },
{ date:'2022-01-26', extracted:'冷战', text:'听完了冷战', status:'已读' },
{ date:'2022-01-24', extracted:'蛤蟆先生看心理医生', text:'蛤蟆先生看心理医生', status:'已读' },
{ date:'2022-01-12', extracted:'它们没大脑,但它们有智能', text:'它们没大脑,但它们有智能', status:'已读' },
{ date:'2021-11-28', extracted:'儿童爱之语', text:'儿童爱之语', status:'已读' },
{ date:'2021-01-01', extracted:'悲惨世界/飘/战争与和平/世纪三部曲', text:'听完了悲惨世界,飘,战争与和平,世纪三部曲', status:'已读' },
{ date:'2021-01-01', extracted:'哈利波特英文版1', text:'哈利波特英文版1', status:'已读' },
{ date:'2021-01-01', extracted:'让孩子成才的秘密', text:'让孩子成才的秘密', status:'已读' },
// ===== 在读 =====
{ date:'', extracted:'老友、爱人和大麻烦', text:'老友、爱人和大麻烦', status:'在读' },
{ date:'', extracted:'图像处理和分析教程', text:'图像处理和分析教程', status:'在读' },
{ date:'', extracted:'想呀想呀', text:'想呀想呀 吉竹伸介', status:'在读' },
{ date:'', extracted:'经济学就这么有趣', text:'经济学就这么有趣', status:'在读' },
{ date:'', extracted:'半小时漫画', text:'半小时漫画', status:'在读' },
{ date:'', extracted:'物种起源', text:'物种起源', status:'在读' },
{ date:'', extracted:'自然哲学的数学原理', text:'自然哲学的数学原理', status:'在读' },
{ date:'', extracted:'被讨厌的勇气', text:'被讨厌的勇气(重读)', status:'在读' },
{ date:'', extracted:'Big Ideas from History', text:'Big Ideas from History', status:'在读' },
{ date:'', extracted:'史玉柱', text:'史玉柱', status:'在读' },
{ date:'', extracted:'完整的成长', text:'完整的成长', status:'在读' },
{ date:'', extracted:'海龟交易法则', text:'海龟交易法则', status:'在读' },
{ date:'', extracted:'国富论', text:'国富论', status:'在读' },
{ date:'', extracted:'滚雪球', text:'滚雪球', status:'在读' },
{ date:'', extracted:'How Humans Took Over the World', text:'How Humans Took Over the World', status:'在读' },
{ date:'', extracted:'Weather Watching', text:'Weather Watching', status:'在读' },
{ date:'', extracted:'穷查理宝典', text:'穷查理宝典', status:'在读' },
{ date:'', extracted:'Big Ideas of Literature', text:'Big Ideas of Literature', status:'在读' },
{ date:'', extracted:'谷物大脑', text:'谷物大脑及相关', status:'在读' },
{ date:'', extracted:'图解皇帝内经', text:'图解皇帝内经', status:'在读' },
// ===== 在听 =====
{ date:'', extracted:'中世纪三部曲', text:'中世纪三部曲', status:'在听' },
{ date:'', extracted:'枪炮', text:'枪炮', status:'在听' },
{ date:'', extracted:'基督山伯爵', text:'基督山伯爵', status:'在听' },
{ date:'', extracted:'汤姆叔叔的小屋', text:'汤姆叔叔的小屋', status:'在听' },
{ date:'', extracted:'简明日本史', text:'简明日本史', status:'在听' },
// ===== 准备读 =====
{ date:'', extracted:'免疫', text:'免疫', status:'待读' },
{ date:'', extracted:'孩子财商', text:'孩子财商', status:'待读' },
{ date:'', extracted:'摩托车修理与禅', text:'摩托车修理与禅', status:'待读' },
{ date:'', extracted:'人类误判心理学', text:'人类误判心理学', status:'待读' },
{ date:'', extracted:'医学的原理和实践', text:'医学的原理和实践', status:'待读' },
{ date:'', extracted:'心经、金刚经', text:'心经、金刚经', status:'待读' },
{ date:'', extracted:'运动解剖学原理', text:'运动解剖学原理', status:'待读' },
];
readingDoc.entries = importData.map((d, i) => ({
text: d.text,
extracted: d.extracted,
status: d.status,
noteId: 0,
time: d.date ? d.date.slice(0,7).replace('-','.') : '',
date: d.date || '',
}));
// 备注
readingDoc.notes = '借出游戏力3 借给了邓薇 | 借入亦桐4本书';
saveDocs();
}
localStorage.setItem('sp_import_reading_v1', '1');
}
// 睡眠数据导入
if (!localStorage.getItem('sp_import_sleep_v1')) {
// 格式: { 'YYYY-MM': { 'HH:MM': [day, day, ...] } }
// 时间取每个区间中点
const sleepImport = {
'2026-03': { '21:30':['21'], '23:30':['1','3','4','12','13','15','17','19','20','22','23'], '00:30':['5','7','8','9','11','14','16','18','24'], '01:30':['2','6','10'] },
'2026-02': { '22:30':['17','18','22'], '23:30':['1','4','6','7','10','11','12','13','19','21','23','24','25','26','27','28'], '00:30':['2','5','8','9','14','16'], '01:30':['15','20'], '02:30':['3'] },
'2026-01': { '22:30':['9','10','15'], '23:30':['1','3','7','8','11','12','14','20','23','25','31'], '00:30':['6','13','16','17','19','22','24','26','30'], '01:30':['2','4','5','18','21','27','28','29'] },
'2025-12': { '22:30':['5','7'], '23:30':['2','4','8','9','10','11','12','14','16','17','30'], '00:30':['1','3','6','15','18','19','20','23','26'], '01:30':['13','21','22','24','25','27','29','31'], '02:30':['28'] },
'2025-11': { '22:30':['4'], '23:30':['5','6','7','9','11','12','13','14','18','19','20','21','28','30'], '00:30':['1','2','3','8','10','15','16','17','22','23','25','26','27'], '01:30':['24'], '02:30':['29'] },
'2025-10': { '22:30':['2','26'], '23:30':['1','3','4','5','7','8','9','10','11','12','13','14','29','30','31'], '00:30':['6','15','16','17','18','19','20','21','22','23','24','25','27','28'] },
'2025-09': { '22:30':['2','7','10','12','13'], '23:30':['1','3','4','5','8','9','11','14','15','16','17','19','20','21','22','24','26','27','29','30'], '00:30':['6','18','23','25','28'] },
'2025-08': { '22:30':['13','29','30'], '23:30':['4','5','6','9','10','16','20','21','22','24'], '00:30':['2','7','8','11','12','14','15','17','18','19','23','25','26','27','28'], '01:30':['1','3'], '02:30':['31'] },
'2025-07': { '22:30':['3','23'], '23:30':['1','2','4','5','6','8','9','12','16','18','20','25','26','27','28','29','30'], '00:30':['13','15','21'], '01:30':['7','17','19','22','24','31'], '02:30':['14'], '03:30':['11'] },
'2025-06': { '21:30':['22'], '22:30':['1','2','10','11','13','15','17','20','23','25','27','29'], '23:30':['3','4','5','6','8','9','12','14','16','18','19','21','24','26','30'], '00:30':['28'], '03:30':['7'] },
'2025-05': { '21:30':['26'], '22:30':['6','7','8','14','18','24','25'], '23:30':['1','2','3','4','9','10','11','12','13','15','16','19','20','21','22','28','30'], '00:30':['5','17','23','29','31'], '02:30':['27'] },
'2025-04': { '22:30':['11','14','15','17'], '23:30':['10','12','13','16','18','19','20','21','22','23','25','28','29'], '00:30':['1','2','6','7','8','9','24','26','27','30'], '02:30':['3','4','5'] },
'2025-03': { '22:30':['6','8','10','11'], '23:30':['1','4','5','9','12','13','14','16','24','26','27','28','29'], '00:30':['7','17','18','20','23','25','31'], '01:30':['19','21','22','30'], '02:30':['15'], '03:30':['2','3'] },
'2025-02': { '22:30':['5','6','14','15','18','25'], '23:30':['1','3','7','8','9','10','12','13','16','17','19','21','22','23','24','26','28'], '00:30':['2','4','11','20','27'] },
'2025-01': { '22:30':['10','13','21'], '23:30':['2','6','7','8','9','11','12','14','15','17','18','19','20','22','23','24'], '00:30':['3','4','5','16','25','26','27','28','29','30','31'], '01:30':['1'] },
};
const imported = [];
for (const [ym, ranges] of Object.entries(sleepImport)) {
const [y, m] = ym.split('-');
for (const [time, days] of Object.entries(ranges)) {
for (const d of days) {
const dateStr = `${y}-${m}-${d.padStart(2,'0')}`;
// 避免重复
if (!sleepData.find(r => r.date === dateStr)) {
imported.push({ date: dateStr, start: time });
}
}
}
}
sleepData.push(...imported);
sleepData.sort((a,b) => b.date.localeCompare(a.date));
saveSleep();
localStorage.setItem('sp_import_sleep_v1', '1');
}
// 修正吹号图标
if (!localStorage.getItem('sp_fix_trumpet_v1')) {
const mi = JSON.parse(localStorage.getItem('sp_music_items'));
if (mi) {
const t = mi.find(i => i.id === 'm_trumpet');
if (t && t.emoji === '📯') { t.emoji = '🎺'; localStorage.setItem('sp_music_items', JSON.stringify(mi)); musicItems = mi; }
}
localStorage.setItem('sp_fix_trumpet_v1', '1');
}
// 修正头疗图标
if (!localStorage.getItem('sp_fix_headtherapy_v1')) {
const hi = JSON.parse(localStorage.getItem('sp_health_items'));
if (hi) {
const t = hi.find(i => i.id === 'h_headtherapy');
if (t && t.emoji !== '💆') { t.emoji = '💆'; localStorage.setItem('sp_health_items', JSON.stringify(hi)); healthItems = hi; }
}
localStorage.setItem('sp_fix_headtherapy_v1', '1');
}
// 清理读书记录重复:已读里有的,在读里就去掉
if (!localStorage.getItem('sp_fix_reading_dups_v1')) {
const readDoc = personalDocs.find(d => d.id === 'doc_reading');
if (readDoc) {
const doneBooks = new Set(readDoc.entries.filter(e => e.status === '已读').map(e => e.extracted).filter(Boolean));
const before = readDoc.entries.length;
readDoc.entries = readDoc.entries.filter(e => {
if (e.status === '在读' && e.extracted && doneBooks.has(e.extracted)) return false;
return true;
});
if (readDoc.entries.length !== before) { saveDocs(); }
}
localStorage.setItem('sp_fix_reading_dups_v1', '1');
}
// ============================================================
// 手机左右滑动切换 Tab
// ============================================================
let touchStartX = 0, touchStartY = 0, touchInScrollable = false;
document.addEventListener('touchstart', e => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
// 检查手指是否落在可横向滚动的容器内(如月历表格)
touchInScrollable = false;
let el = e.target;
while (el && el !== document.body) {
if (el.scrollWidth > el.clientWidth + 5) { touchInScrollable = true; break; }
el = el.parentElement;
}
}, { passive: true });
document.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const tag = document.activeElement?.tagName;
if (tag === 'TEXTAREA' || tag === 'INPUT') return;
// 在可横向滚动区域(月历、表格等)内不切换 Tab
if (touchInScrollable) return;
const tabs = [...document.querySelectorAll('.tab-btn')];
const idx = tabs.findIndex(t => t.dataset.tab === currentTab);
if (dx < 0 && idx < tabs.length - 1) tabs[idx + 1].click();
else if (dx > 0 && idx > 0) tabs[idx - 1].click();
}, { passive: true });
// ============================================================
// 9a. 健身记录
// ============================================================
let gymData = JSON.parse(localStorage.getItem('sp_gym')) || [];
function saveGym() { localStorage.setItem('sp_gym', JSON.stringify(gymData)); markLocalDirty(); }
async function openAddGym() {
const text = await showDialog('记录今天的训练\n格式动作名 重量,如:\n臀推 30kg*2\n飞鸟 4kg*2', 'prompt', '');
if (!text || !text.trim()) return;
const today = dateKey(new Date());
let rec = gymData.find(r => r.date === today);
if (!rec) { rec = { date: today, items: [], id: Date.now() }; gymData.unshift(rec); }
text.trim().split('\n').forEach(line => {
line = line.trim();
if (!line) return;
const m = line.match(/^(.+?)\s+(.+)$/);
if (m) rec.items.push({ exercise: m[1].trim(), weight: m[2].trim() });
else rec.items.push({ exercise: line, weight: '' });
});
saveGym(); renderGym(); pushNow();
}
function renderGym() {
const el = document.getElementById('gymList');
if (!el) return;
if (!gymData.length) { el.innerHTML = '<div style="text-align:center;color:#ccc;padding:30px;font-size:13px;">还没有健身记录</div>'; return; }
el.innerHTML = gymData.slice(0, 60).map(r => {
const items = (r.items || []).map(i =>
`<div style="display:flex;gap:8px;padding:3px 0;font-size:13px;">
<span style="color:#667eea;min-width:80px;">${escHtml(i.exercise)}</span>
<span style="color:#666;">${escHtml(i.weight)}</span>
</div>`
).join('');
return `<div style="background:white;border-radius:10px;padding:12px 14px;margin-bottom:8px;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<span style="font-size:12px;color:#aaa;">${r.date}</span>
<button style="font-size:11px;color:#ccc;background:none;border:none;cursor:pointer;" onclick="deleteGymRecord('${r.date}')">删除</button>
</div>
${items || '<span style="color:#ccc;font-size:12px;">无详细记录</span>'}
</div>`;
}).join('');
}
async function deleteGymRecord(date) {
if (!await showDialog('确定删除这天的健身记录?','confirm')) return;
gymData = gymData.filter(r => r.date !== date);
saveGym(); renderGym(); pushNow();
}
// ============================================================
// 9b. 经期记录
// ============================================================
let periodData = JSON.parse(localStorage.getItem('sp_period')) || [];
function savePeriod() { localStorage.setItem('sp_period', JSON.stringify(periodData)); markLocalDirty(); }
async function openAddPeriod() {
const text = await showDialog('记录经期开始日期\n如今天 / 3.19 / 2026-03-19', 'prompt', '');
if (!text || !text.trim()) return;
let dateStr;
if (/今天|今日/.test(text)) {
dateStr = dateKey(new Date());
} else {
const m = text.match(/(\d{4})?[.\-\/]?(\d{1,2})[.\-\/](\d{1,2})/);
if (m) {
const y = m[1] || new Date().getFullYear();
dateStr = `${y}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}`;
} else return;
}
// 避免重复
if (periodData.find(r => r.start === dateStr)) { showToast('该日期已有记录'); return; }
periodData.push({ start: dateStr, cycle_length: 0, id: Date.now() });
periodData.sort((a,b) => b.start.localeCompare(a.start));
// 自动算周期长度
for (let i = 0; i < periodData.length - 1; i++) {
const d1 = new Date(periodData[i].start), d2 = new Date(periodData[i+1].start);
periodData[i+1].cycle_length = Math.round((d1 - d2) / 86400000);
}
savePeriod(); renderPeriod(); pushNow();
}
function renderPeriod() {
const el = document.getElementById('periodList');
const summary = document.getElementById('periodSummary');
if (!el) return;
// 统计
const recent = periodData.filter(r => r.cycle_length > 0).slice(0, 6);
const avgCycle = recent.length ? Math.round(recent.reduce((a,r) => a + r.cycle_length, 0) / recent.length) : 28;
const lastStart = periodData[0]?.start;
let nextEstimate = '';
if (lastStart) {
const next = new Date(lastStart);
next.setDate(next.getDate() + avgCycle);
nextEstimate = `${next.getMonth()+1}/${next.getDate()}`;
}
const today = new Date();
const daysSinceLast = lastStart ? Math.round((today - new Date(lastStart)) / 86400000) : 0;
if (summary) {
summary.innerHTML = `<div style="display:flex;gap:10px;">
<div style="flex:1;background:white;border-radius:10px;padding:14px;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<div style="font-size:22px;font-weight:700;color:#f472b6;">${avgCycle}</div>
<div style="font-size:11px;color:#aaa;">平均周期(天)</div>
</div>
<div style="flex:1;background:white;border-radius:10px;padding:14px;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<div style="font-size:22px;font-weight:700;color:#667eea;">第${daysSinceLast}天</div>
<div style="font-size:11px;color:#aaa;">当前周期</div>
</div>
<div style="flex:1;background:white;border-radius:10px;padding:14px;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<div style="font-size:22px;font-weight:700;color:#a78bfa;">${nextEstimate||'--'}</div>
<div style="font-size:11px;color:#aaa;">预计下次</div>
</div>
</div>`;
}
if (!periodData.length) { el.innerHTML = '<div style="text-align:center;color:#ccc;padding:20px;font-size:13px;">还没有记录</div>'; return; }
el.innerHTML = periodData.slice(0, 30).map(r => {
return `<div style="display:flex;align-items:center;background:white;border-radius:10px;padding:10px 14px;margin-bottom:6px;box-shadow:0 1px 4px rgba(0,0,0,0.04);">
<span style="font-size:13px;color:#f472b6;font-weight:500;min-width:90px;">${r.start}</span>
<span style="font-size:12px;color:#aaa;flex:1;">${r.cycle_length ? r.cycle_length + '天周期' : '最新'}</span>
<button style="font-size:11px;color:#ccc;background:none;border:none;cursor:pointer;" onclick="deletePeriodRecord('${r.start}')">删除</button>
</div>`;
}).join('');
}
async function deletePeriodRecord(start) {
if (!await showDialog('确定删除这条经期记录?','confirm')) return;
periodData = periodData.filter(r => r.start !== start);
savePeriod(); renderPeriod(); pushNow();
}
// ============================================================
// 初始化
// ============================================================
function reloadAllData() {
// 从 localStorage 重新加载所有数据变量
modules = JSON.parse(localStorage.getItem('sp_modules')) || DEFAULT_MODULES;
scheduleData = JSON.parse(localStorage.getItem('sp_schedule')) || {};
todos = JSON.parse(localStorage.getItem('sp_todos')) || { q1:[], q2:[], q3:[], q4:[] };
inbox = JSON.parse(localStorage.getItem('sp_inbox')) || [];
reviews = JSON.parse(localStorage.getItem('sp_reviews')) || {};
personalDocs = JSON.parse(localStorage.getItem('sp_docs')) || DEFAULT_DOCS;
sleepData = JSON.parse(localStorage.getItem('sp_sleep')) || [];
healthItems = JSON.parse(localStorage.getItem('sp_health_items')) || DEFAULT_HEALTH_ITEMS;
healthPlans = JSON.parse(localStorage.getItem('sp_health_plans')) || {};
healthChecks = JSON.parse(localStorage.getItem('sp_health_checks')) || {};
musicItems = JSON.parse(localStorage.getItem('sp_music_items')) || DEFAULT_MUSIC_ITEMS;
musicPlans = JSON.parse(localStorage.getItem('sp_music_plans')) || {};
musicChecks = JSON.parse(localStorage.getItem('sp_music_checks')) || {};
healthDiary = JSON.parse(localStorage.getItem('sp_health_diary')) || [];
goals = JSON.parse(localStorage.getItem('sp_goals')) || [];
checklists = JSON.parse(localStorage.getItem('sp_checklists')) || [];
bugs = JSON.parse(localStorage.getItem('sp_bugs')) || [];
gymData = JSON.parse(localStorage.getItem('sp_gym')) || [];
periodData = JSON.parse(localStorage.getItem('sp_period')) || [];
}
// 新月份自动复制上月打卡计划
function autoCarryOverPlans() {
const now = new Date();
const mk = getMonthKey(now);
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const pmk = getMonthKey(prev);
let changed = false;
// 健康打卡
if ((!healthPlans[mk] || healthPlans[mk].length === 0) && healthPlans[pmk] && healthPlans[pmk].length > 0) {
healthPlans[mk] = [...healthPlans[pmk]];
changed = true;
}
// 音乐打卡
if ((!musicPlans[mk] || musicPlans[mk].length === 0) && musicPlans[pmk] && musicPlans[pmk].length > 0) {
musicPlans[mk] = [...musicPlans[pmk]];
changed = true;
}
if (changed) {
localStorage.setItem('sp_health_plans', JSON.stringify(healthPlans));
localStorage.setItem('sp_music_plans', JSON.stringify(musicPlans));
markLocalDirty();
}
}
function initApp() {
autoCarryOverPlans();
renderModules();
renderColorPicker();
renderTimeline();
refreshCurrentTab();
updateHeaderActions();
initPlannerSleepTarget();
if (currentTab === 'notes') {
setTimeout(() => document.getElementById('notesCaptureInput')?.focus({ preventScroll:true }), 200);
}
}
if (isLoggedIn()) {
loadFromServer().then(() => { reloadAllData(); syncReady = true; initApp(); });
}
// 定期备份到 IndexedDB每5分钟+ 页面关闭前备份+推送
setInterval(saveLocalSnapshot, 300000);
window.addEventListener('beforeunload', () => {
saveLocalSnapshot();
// 关闭页面前用 sendBeacon 确保数据推到服务器(不会被浏览器取消)
if (localDirty && isLoggedIn()) {
const data = collectLocalData();
data.sp_pass_hash && delete data.sp_pass_hash; // beacon 无 auth 但服务器不检查
navigator.sendBeacon('/api/data', new Blob([JSON.stringify(data)], { type: 'application/json' }));
}
});
// 页面切回前台/隐藏时推送
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 切到后台时立即推送(防止用户切走后杀进程)
if (localDirty && syncReady) { localDirty = false; pushToServer(); }
} else {
// 切回前台时拉取+推送
if (syncReady) pushToServer();
}
});
// 自动检测代码更新60秒检查一次版本变化时自动刷新
(function autoReload() {
let knownVersion = null;
async function check() {
try {
const r = await fetch('/api/version');
const d = await r.json();
if (knownVersion === null) { knownVersion = d.v; return; }
if (d.v !== knownVersion) {
console.log('代码已更新,自动刷新…');
location.reload();
}
} catch(e) {}
}
check();
setInterval(check, 60000);
})();
</script>
</body>
</html>