7609 lines
297 KiB
HTML
7609 lines
297 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<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' };
|
||
|
||
// 恢复上次的 Tab(TAB_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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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" / "11:20" / "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.26,11点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) {
|
||
// 用 id(timestamp)判断创建时间
|
||
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>
|