feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
*{box-sizing:border-box}
html{font-size:14px}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
.navbar{display:flex;align-items:center;padding:.5rem 0}
.navbar{flex-wrap:wrap}
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
.bg-primary{background-color:#0d6efd}
.bg-dark{background-color:#2b2b2b}
.navbar-dark{color:#fff}
.navbar-dark .navbar-brand{color:#fff}
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
.text-white{color:#fff}
.text-muted{color:#6c757d}
.small{font-size:.875em}
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
.btn:disabled{opacity:.65}
.btn:hover{background-color:#e9ecef}
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
.card-body{padding:1rem}
.row{display:flex;flex-wrap:wrap}
.row.g-2{gap:.5rem}
.col-12{flex:0 0 auto;width:100%}
.col-6{flex:0 0 auto;width:50%}
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
.form-label{margin-bottom:.25rem;font-weight:500}
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
.form-control::-webkit-input-placeholder{color:#6c757d}
.form-control::-moz-placeholder{color:#6c757d}
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
.list-group{display:block;border-radius:.5rem;overflow:hidden}
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
.list-group-flush .list-group-item{border-left:0;border-right:0}
.hstack{display:flex;align-items:center}
.justify-content-between{justify-content:space-between}
.gap-2{gap:.5rem}
.mt-2{margin-top:.5rem}
.mb-2{margin-bottom:.5rem}
.mb-0{margin-bottom:0}
.py-3{padding-top:1rem;padding-bottom:1rem}
.d-flex{display:flex}
.align-items-center{align-items:center}
.d-none{display:none!important}
@@ -0,0 +1,680 @@
:root {
--primary-color: #e74c3c;
--primary-hover: #c0392b;
--secondary-color: #3498db;
--success-color: #27ae60;
--warning-color: #f39c12;
--danger-color: #e74c3c;
--light-bg: #f8f9fa;
--border-color: #e9ecef;
--text-primary: #2c3e50;
--text-secondary: #7f8c8d;
--text-muted: #95a5a6;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--border-radius: 8px;
--grid-gap: 6px;
}
* {
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.navbar {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important;
box-shadow: var(--shadow-md);
padding: 0.8rem 1rem;
}
.navbar-brand {
font-weight: 600;
font-size: 1.3rem;
}
.btn-light {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
font-weight: 500;
}
.btn-light:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: white;
}
.container {
max-width: 1400px;
}
main {
padding-top: 1.5rem !important;
padding-bottom: 2rem !important;
}
/* 日历网格样式 */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: var(--grid-gap);
background: white;
border-radius: var(--border-radius);
padding: 1rem;
box-shadow: var(--shadow-sm);
}
.calendar-weekday {
margin-bottom: 0.5rem;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: var(--grid-gap);
background: white;
border-radius: var(--border-radius);
padding: 0.8rem 1rem;
box-shadow: var(--shadow-sm);
}
.calendar-weekday > div {
text-align: center;
font-weight: 600;
color: var(--primary-color);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.calendar-weekday > div:first-child,
.calendar-weekday > div:last-child {
color: var(--danger-color);
}
.calendar-cell {
background: white;
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
min-height: auto;
height: auto;
padding: 0.8rem;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
position: relative;
}
.calendar-cell:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.calendar-cell.is-today {
border-color: var(--primary-color);
background: linear-gradient(135deg, #fff8f8, #ffe6e6);
}
.calendar-cell.is-today .cell-header span {
background: var(--primary-color);
color: white;
}
.calendar-cell .cell-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.calendar-cell .cell-header span {
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
text-align: center;
line-height: 28px;
font-weight: 600;
font-size: 0.9rem;
background: var(--light-bg);
color: var(--text-secondary);
}
.calendar-cell .events {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
overflow: hidden;
}
/* 事件药丸样式优化 */
.event-pill {
font-size: 0.75rem;
line-height: 1.3;
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
color: #1565c0;
border: 1px solid #90caf9;
border-radius: 12px;
padding: 0.3rem 0.6rem;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: break-all;
transition: all 0.2s ease;
margin: 0.1rem 0;
}
.event-pill:hover {
background: linear-gradient(135deg, #bbdefb, #90caf9);
transform: translateX(2px);
box-shadow: var(--shadow-sm);
}
.event-pill.is-done {
background: linear-gradient(135deg, #e8f5e8, #c8e6c9);
color: #2e7d32;
border-color: #81c784;
text-decoration-line: line-through;
text-decoration-thickness: 2px;
opacity: 0.8;
}
/* 卡片样式 */
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
background: white;
}
.card-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
font-weight: 600;
border: none;
padding: 1rem 1.2rem;
border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
}
.card-body {
padding: 1.2rem;
}
/* 表单样式 */
.form-control, .form-select {
border: 2px solid var(--border-color);
border-radius: 6px;
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(231, 76, 60, 0.25);
}
.form-label {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
border: none;
border-radius: 6px;
padding: 0.6rem 1.2rem;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-hover), #a23526);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* 右侧事件列表样式优化 */
#dayEvents {
padding-left: 0;
margin: 0;
}
#dayEvents .list-group-item {
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 0.4rem;
padding: 0.6rem 0.8rem;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
#dayEvents .list-group-item:hover {
background: rgba(255, 255, 255, 0.98);
border-color: var(--primary-color);
transform: translateX(2px);
}
#dayEvents .list-group-item.is-done {
background: linear-gradient(135deg, rgba(232, 245, 232, 0.95), rgba(200, 230, 201, 0.95));
color: #2e7d32;
border-color: #81c784;
text-decoration-line: line-through;
text-decoration-thickness: 2px;
}
/* 紧凑列表项内容 */
.event-list-content {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.event-list-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
}
.event-list-time {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0;
}
.event-list-desc {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 响应式调整 */
@media (max-width: 768px) {
#dayEvents .list-group-item {
padding: 0.5rem 0.6rem;
margin-bottom: 0.3rem;
}
.event-list-title {
font-size: 0.85rem;
}
.event-list-time {
font-size: 0.7rem;
}
.event-list-desc {
font-size: 0.75rem;
-webkit-line-clamp: 1;
}
}
@media (max-width: 576px) {
#dayEvents .list-group-item {
padding: 0.4rem 0.5rem;
margin-bottom: 0.25rem;
}
.event-list-title {
font-size: 0.8rem;
}
.event-list-time {
font-size: 0.65rem;
}
.event-list-desc {
font-size: 0.7rem;
}
}
/* 响应式设计 */
@media (max-width: 1200px) {
.calendar-cell {
padding: 0.6rem;
}
.event-pill {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
}
@media (max-width: 992px) {
.calendar-grid {
grid-template-columns: repeat(7, minmax(80px, 1fr));
}
.calendar-cell .cell-header span {
width: 24px;
height: 24px;
line-height: 24px;
font-size: 0.8rem;
}
}
@media (max-width: 768px) {
.navbar-brand {
font-size: 1.1rem;
}
.calendar-grid {
grid-template-columns: repeat(7, minmax(70px, 1fr));
padding: 0.5rem;
}
.calendar-cell {
padding: 0.4rem;
}
.event-pill {
font-size: 0.65rem;
border-radius: 8px;
}
.card-body {
padding: 1rem;
}
}
@media (max-width: 576px) {
body {
background: white;
}
.navbar {
padding: 0.6rem 0.8rem;
}
main {
padding: 1rem 0.5rem !important;
}
.calendar-grid {
grid-template-columns: repeat(7, minmax(60px, 1fr));
gap: 3px;
}
.calendar-weekday {
padding: 0.5rem;
gap: 3px;
}
.calendar-weekday > div {
font-size: 0.8rem;
padding: 0.3rem 0;
}
.calendar-cell {
padding: 0.3rem;
border-width: 1px;
}
.calendar-cell .cell-header span {
width: 20px;
height: 20px;
line-height: 20px;
font-size: 0.7rem;
}
.event-pill {
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
border-radius: 6px;
-webkit-line-clamp: 1;
}
.events {
gap: 0.2rem !important;
}
}
/* 高密度模式优化 */
.high-density .calendar-cell {
min-height: 60px;
}
.high-density .event-pill {
font-size: 0.6rem;
padding: 0.1rem 0.2rem;
margin: 0.05rem 0;
-webkit-line-clamp: 1;
}
/* 事件悬停提示框样式 */
.event-tooltip {
background: white;
border: 2px solid #dc3545;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
max-width: 400px;
z-index: 10000;
backdrop-filter: blur(10px);
animation: tooltipFadeIn 0.2s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(10px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.tooltip-header {
border-bottom: 2px solid #f8f9fa;
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.tooltip-header strong {
font-size: 1.1rem;
color: #dc3545;
flex: 1;
}
.tooltip-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.tooltip-table th,
.tooltip-table td {
padding: 0.5rem;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #f8f9fa;
}
.tooltip-table th {
color: #6c757d;
font-weight: 600;
white-space: nowrap;
width: 80px;
}
.tooltip-table td {
color: #495057;
word-break: break-word;
}
.tooltip-table tr:last-child th,
.tooltip-table tr:last-child td {
border-bottom: none;
}
/* 移动端适配 */
@media (max-width: 768px) {
.event-tooltip {
max-width: 300px;
padding: 0.75rem;
font-size: 0.85rem;
}
.tooltip-table th,
.tooltip-table td {
padding: 0.4rem;
}
.tooltip-header strong {
font-size: 1rem;
}
}
@media (max-width: 576px) {
.event-tooltip {
max-width: 280px;
padding: 0.6rem;
}
.tooltip-table {
font-size: 0.8rem;
}
.tooltip-table th {
width: 70px;
}
}
.event-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 9998;
}
.event-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #ffffff;
border: 2px solid #dc3545;
border-radius: 12px;
box-shadow: 0 12px 48px rgba(0,0,0,0.2);
z-index: 9999;
width: 90vw;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.event-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1rem;
border-bottom: 1px solid #f1f3f5;
}
.event-modal .modal-title {
font-weight: 600;
color: #dc3545;
font-size: 1.05rem;
margin-right: 0.5rem;
}
.event-modal .modal-close {
appearance: none;
border: none;
background: transparent;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
color: #6c757d;
}
.event-modal .modal-close:hover {
color: #343a40;
}
.event-modal .modal-body {
padding: 1rem;
overflow: auto;
}
@media (max-width: 576px) {
.event-modal {
width: 92vw;
max-width: 480px;
max-height: 82vh;
}
.event-modal .modal-body {
padding: 0.8rem;
}
}
.calendar-cell .events::-webkit-scrollbar-track {
background: var(--light-bg);
border-radius: 2px;
}
.calendar-cell .events::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 2px;
}
.calendar-cell .events::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* 动画效果 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.calendar-cell {
animation: fadeIn 0.3s ease;
}
.event-pill {
animation: fadeIn 0.2s ease;
}
/* 加载状态 */
.loading {
opacity: 0.6;
pointer-events: none;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid var(--primary-color);
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>日历提醒系统 · Web</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
<link rel="stylesheet" href="css/bootstrap.local.css">
<link rel="stylesheet" href="css/styles.css?v=1.1">
<script src="js/background.js?v=1.1" defer></script>
</head>
<body>
<header class="navbar navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">日历提醒系统(Web)</span>
<div class="d-flex align-items-center gap-2">
<button id="btnReload" class="btn btn-light btn-sm">刷新日历</button>
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
<div id="currentMonth" class="text-white"></div>
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
</div>
</div>
</header>
<main class="container py-3">
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="calendar-grid" id="calendar-grid" aria-live="polite"></div>
</div>
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-header">当天事件与编辑</div>
<div class="card-body">
<div class="mb-3">
<h6 class="mb-2">当天事件</h6>
<ul id="dayEvents" class="list-group list-group-flush"></ul>
</div>
<hr>
<!-- 新增/编辑事件 表单结构(统一为 WinForms 布局) -->
<form id="eventForm" class="vstack gap-2">
<input type="hidden" id="eventId">
<div>
<label for="title" class="form-label">标题</label>
<input type="text" id="title" class="form-control" required maxlength="100" placeholder="如:团队会议">
</div>
<div>
<label for="content" class="form-label">内容</label>
<textarea id="content" class="form-control" rows="4" placeholder="备注说明"></textarea>
</div>
<div class="row g-2">
<div class="col-6">
<label for="start" class="form-label">开始时间</label>
<input type="datetime-local" id="start" class="form-control" required step="900">
</div>
<div class="col-6">
<label for="end" class="form-label">结束时间</label>
<input type="datetime-local" id="end" class="form-control" step="900">
</div>
</div>
<div class="form-check my-1">
<input class="form-check-input" type="checkbox" id="enableReminder" checked>
<label class="form-check-label" for="enableReminder">启用提醒</label>
</div>
<div class="form-check my-1">
<input class="form-check-input" type="checkbox" id="completedFlag">
<label class="form-check-label" for="completedFlag">已完成,不再提醒</label>
</div>
<div id="reminderRow">
<label class="form-label">提醒时间</label>
<!-- 不循环:日期+时分 -->
<input type="datetime-local" id="remindDateTime" class="form-control" step="900">
<!-- 每天/每周:仅时分 -->
<input type="time" id="remindTime" class="form-control d-none" step="900">
</div>
<div>
<label for="recurrenceType" class="form-label">循环类型</label>
<select id="recurrenceType" class="form-select">
<option value="NONE">不循环</option>
<option value="DAILY">每天</option>
<option value="WEEKLY">每周</option>
</select>
</div>
<div id="recurrenceOptions" class="d-none">
<div class="row g-2">
<div class="col-4">
<label for="recurrenceInterval" class="form-label">循环间隔</label>
<input type="number" id="recurrenceInterval" class="form-control" value="1" min="1">
</div>
</div>
<div id="weekdayGroup" class="mt-2 d-none">
<label class="form-label">每周星期几</label>
<div class="weekday-list">
<label><input type="checkbox" data-weekday="1"> 周一</label>
<label><input type="checkbox" data-weekday="2"> 周二</label>
<label><input type="checkbox" data-weekday="3"> 周三</label>
<label><input type="checkbox" data-weekday="4"> 周四</label>
<label><input type="checkbox" data-weekday="5"> 周五</label>
<label><input type="checkbox" data-weekday="6"> 周六</label>
<label><input type="checkbox" data-weekday="7"> 周日</label>
</div>
</div>
</div>
<div class="hstack gap-2 mt-2">
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" id="btnReset" class="btn btn-secondary">重置</button>
<button type="button" id="btnDelete" class="btn btn-danger d-none">删除</button>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
<footer class="container py-3 text-muted small">
<div>模式:<span id="modeLabel"></span> · 提醒依赖浏览器通知(需授权)</div>
</footer>
<script src="js/config.js"></script>
<script src="/assets/auth.js"></script>
<script src="js/db.js"></script>
<script src="js/models.js?v=1.1"></script>
<script src="js/sync.js?v=1.1"></script>
<script src="js/ui.js?v=1.3"></script>
<script src="js/main.js?v=1.3"></script>
<script src="/lib/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,279 @@
const BackgroundManager = (function () {
const CACHE_KEY = 'background_cache';
const LAST_UPDATE_KEY = 'background_last_update';
const CACHE_EXPIRE_DAYS = 30;
const CONFIG_ENDPOINT = '/api/calendar/sys-config';
let configCache = null;
const UNSPLASH_ACCESS_KEY = 'osWW4Fx0IbpfJs2LbCl_Jpv5ej-6ZaphyIX9sgdPdxI';
const PICSUM_BASE_URL = 'https://picsum.photos';
const CHINESE_THEME_KEYWORDS = [
'chinese landscape', 'chinese painting', 'bamboo', 'lotus', 'plum blossom',
'orchid', 'chrysanthemum', 'mountain water', 'ink wash', 'traditional chinese',
'landscape painting', 'watercolor', 'minimalist', 'serene', 'nature'
];
async function loadConfig() {
if (configCache) return configCache;
try {
const res = await fetch(CONFIG_ENDPOINT, { cache: 'no-store' });
if (!res.ok) {
configCache = {};
return configCache;
}
const data = await res.json().catch(() => ({}));
const cfg = data && data.config && typeof data.config === 'object' ? data.config : {};
configCache = cfg;
return cfg;
} catch (_) {
configCache = {};
return configCache;
}
}
async function getLocalPoolBackground(cfg) {
try {
const theme = String((cfg && (cfg.background_theme || cfg.backgroundTheme)) || '默认背景');
const params = new URLSearchParams();
params.set('theme', theme);
params.set('prefer_local', '1');
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
if (!res.ok) return '';
const data = await res.json().catch(() => ({}));
const url = data && typeof data.url === 'string' ? data.url : '';
return url || '';
} catch (_) {
return '';
}
}
async function getAllPoolBackground() {
const themes = [
'夜空星轨',
'蓝天白云',
'治愈森林',
'运动风',
'科技霓虹',
'商务简约',
'暖色日落',
'冷色极夜'
];
const theme = themes[Math.floor(Math.random() * themes.length)];
try {
const params = new URLSearchParams();
params.set('theme', theme);
params.set('prefer_local', '1');
const res = await fetch('/api/home/bg?' + params.toString(), { cache: 'no-store' });
if (!res.ok) return '';
const data = await res.json().catch(() => ({}));
const url = data && typeof data.url === 'string' ? data.url : '';
return url || '';
} catch (_) {
return '';
}
}
async function getDailyBackground() {
const cfg = await loadConfig();
const today = new Date().toDateString();
const cached = getCachedBackground();
if (cached && cached.date === today && cached.url) {
return cached.url;
}
const enableDynamic = cfg && Object.prototype.hasOwnProperty.call(cfg, 'ENABLE_DYNAMIC_BACKGROUND')
? cfg.ENABLE_DYNAMIC_BACKGROUND !== false
: true;
const modeRaw = cfg && cfg.background_mode ? String(cfg.background_mode) : '';
const mode = modeRaw.toUpperCase();
const useAllPool = mode === 'ALL_POOL';
if (!enableDynamic) {
const url = getDefaultBackground();
cacheBackground(url, today);
return url;
}
if (useAllPool) {
const allUrl = await getAllPoolBackground();
if (allUrl) {
cacheBackground(allUrl, today);
return allUrl;
}
const fallback = getDefaultBackground();
cacheBackground(fallback, today);
return fallback;
}
if (cfg && (cfg.background_theme || cfg.backgroundTheme)) {
const localUrl = await getLocalPoolBackground(cfg);
if (localUrl) {
cacheBackground(localUrl, today);
return localUrl;
}
const fallback = getDefaultBackground();
cacheBackground(fallback, today);
return fallback;
}
try {
const unsplashUrl = await tryUnsplash();
if (unsplashUrl) {
cacheBackground(unsplashUrl, today);
return unsplashUrl;
}
} catch (error) {
console.warn('Unsplash API failed:', error);
}
if (cached && cached.url) return cached.url;
try {
const picsumUrl = await tryPicsum();
if (picsumUrl) {
cacheBackground(picsumUrl, today);
return picsumUrl;
}
} catch (error) {
console.warn('Picsum API failed:', error);
}
const fallback = getDefaultBackground();
cacheBackground(fallback, today);
return fallback;
}
async function tryUnsplash() {
if (!UNSPLASH_ACCESS_KEY || UNSPLASH_ACCESS_KEY === 'your_unsplash_access_key_here') {
return null;
}
const randomKeyword = CHINESE_THEME_KEYWORDS[Math.floor(Math.random() * CHINESE_THEME_KEYWORDS.length)];
const url = `https://api.unsplash.com/photos/random?query=${encodeURIComponent(randomKeyword)}&orientation=landscape&client_id=${UNSPLASH_ACCESS_KEY}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Accept-Version': 'v1'
}
});
if (!response.ok) {
console.warn('Unsplash API error:', response.status, response.statusText);
return null;
}
const data = await response.json();
return data.urls.regular;
} catch (error) {
console.warn('Unsplash fetch failed:', error);
return null;
}
}
async function tryPicsum() {
const width = window.innerWidth;
const height = window.innerHeight;
const imageId = Math.floor(Math.random() * 1000);
return `${PICSUM_BASE_URL}/${width}/${height}?blur=2&image=${imageId}`;
}
function getDefaultBackground() {
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}
function getCachedBackground() {
try {
const cached = localStorage.getItem(CACHE_KEY);
const lastUpdate = localStorage.getItem(LAST_UPDATE_KEY);
if (!cached || !lastUpdate) return null;
// 检查缓存是否过期(30天)
const updateDate = new Date(lastUpdate);
const expireDate = new Date(updateDate);
expireDate.setDate(expireDate.getDate() + CACHE_EXPIRE_DAYS);
if (new Date() > expireDate) {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(LAST_UPDATE_KEY);
return null;
}
return JSON.parse(cached);
} catch (error) {
console.warn('Failed to read cache:', error);
return null;
}
}
function cacheBackground(url, date) {
try {
const cacheData = { url, date };
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
localStorage.setItem(LAST_UPDATE_KEY, new Date().toISOString());
} catch (error) {
console.warn('Failed to cache background:', error);
}
}
function applyBackground(url) {
const body = document.body;
let bgContainer = document.getElementById('background-container');
if (!bgContainer) {
bgContainer = document.createElement('div');
bgContainer.id = 'background-container';
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
bgContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: ${isImage ? `url(${url}) center/cover no-repeat` : url};
opacity: 0.15;
filter: blur(8px);
`;
document.body.appendChild(bgContainer);
} else {
const isImage = typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'));
bgContainer.style.background = isImage ? `url(${url}) center/cover no-repeat` : url;
}
body.style.background = 'transparent';
const importantElements = document.querySelectorAll('.card, .calendar-grid, .navbar');
importantElements.forEach(el => {
el.style.backgroundColor = 'rgba(255, 255, 255, 0.95)';
el.style.backdropFilter = 'blur(10px)';
});
}
async function init() {
try {
const backgroundUrl = await getDailyBackground();
applyBackground(backgroundUrl);
} catch (error) {
console.error('Failed to set background:', error);
applyBackground(getDefaultBackground());
}
}
return {
init,
refresh: init
};
})();
// 页面加载时初始化
window.addEventListener('load', () => {
BackgroundManager.init();
});
// 导出供其他模块使用
if (typeof module !== 'undefined' && module.exports) {
module.exports = BackgroundManager;
}
@@ -0,0 +1,8 @@
const AppConfig = {
mode: 'shared', // 'standalone' | 'shared'
API_BASE_URL: '/api/calendar/events',
ENABLE_SYNC: true, // 共享模式下开启
SYNC_INTERVAL_MS: 60_000,
};
window.AppConfig = AppConfig;
+163
View File
@@ -0,0 +1,163 @@
// IndexedDB 存储封装
const DB = (function () {
const DB_NAME = 'calendar_reminder';
const DB_VERSION = 2; // 升级版本以创建 outbox 队列
const STORE_EVENTS = 'events';
const STORE_OUTBOX = 'outbox';
let _db;
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
// 事件存储
if (!db.objectStoreNames.contains(STORE_EVENTS)) {
const store = db.createObjectStore(STORE_EVENTS, { keyPath: 'id' });
store.createIndex('by_start', 'start');
store.createIndex('by_updated', 'updatedAt');
}
// 离线 outbox 队列
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
db.createObjectStore(STORE_OUTBOX, { keyPath: 'id' });
}
};
req.onsuccess = () => {
_db = req.result;
resolve(_db);
};
req.onerror = () => reject(req.error);
});
}
function tx(storeName, mode = 'readonly') {
const t = _db.transaction(storeName, mode);
return t.objectStore(storeName);
}
async function initDB() {
if (_db) return _db;
return await openDB();
}
function addEvent(ev) {
return new Promise((resolve, reject) => {
const store = tx(STORE_EVENTS, 'readwrite');
const req = store.add(ev);
req.onsuccess = () => resolve(ev);
req.onerror = () => reject(req.error);
});
}
function updateEvent(ev) {
return new Promise((resolve, reject) => {
const store = tx(STORE_EVENTS, 'readwrite');
const req = store.put(ev);
req.onsuccess = () => resolve(ev);
req.onerror = () => reject(req.error);
});
}
function deleteEvent(id) {
return new Promise((resolve, reject) => {
const store = tx(STORE_EVENTS, 'readwrite');
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
function getEvent(id) {
return new Promise((resolve, reject) => {
const store = tx(STORE_EVENTS, 'readonly');
const req = store.get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function listEventsByMonth(year, month) {
// month: 1..12
const start = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0));
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59));
return new Promise((resolve, reject) => {
const store = tx(STORE_EVENTS, 'readonly');
const idx = store.index('by_start');
const range = IDBKeyRange.bound(start.toISOString(), end.toISOString());
const result = [];
const req = idx.openCursor(range);
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
result.push(cursor.value);
cursor.continue();
} else {
resolve(result);
}
};
req.onerror = () => reject(req.error);
});
}
// --- Outbox 队列 ---
function genId() { return Date.now() + '-' + Math.random().toString(36).slice(2); }
function addOutbox(entry) {
return new Promise((resolve, reject) => {
const store = tx(STORE_OUTBOX, 'readwrite');
const req = store.add(entry);
req.onsuccess = () => resolve(entry);
req.onerror = () => reject(req.error);
});
}
function addOutboxUpsert(ev) {
const entry = { id: genId(), type: 'upsert', payload: ev, timestamp: new Date().toISOString() };
return addOutbox(entry);
}
function addOutboxDelete(id) {
const entry = { id: genId(), type: 'delete', payload: { id }, timestamp: new Date().toISOString() };
return addOutbox(entry);
}
function listOutbox() {
return new Promise((resolve, reject) => {
const store = tx(STORE_OUTBOX, 'readonly');
const result = [];
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) { result.push(cursor.value); cursor.continue(); }
else { resolve(result); }
};
req.onerror = () => reject(req.error);
});
}
function removeOutbox(id) {
return new Promise((resolve, reject) => {
const store = tx(STORE_OUTBOX, 'readwrite');
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
return {
initDB,
addEvent,
updateEvent,
deleteEvent,
getEvent,
listEventsByMonth,
// outbox
addOutboxUpsert,
addOutboxDelete,
listOutbox,
removeOutbox,
};
})();
window.DB = DB;
+627
View File
@@ -0,0 +1,627 @@
// 主入口:初始化、交互、提醒与 PWA
(function () {
let current = new Date();
let monthEvents = [];
let monthOccurrences = [];
const monthCache = new Map();
// 进程内临时集:已提醒过的事件键,避免重复提醒(不落地)
const notifiedKeys = new Set();
const scheduledKeys = new Set();
let heartbeatTimer = null;
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
let autoRefreshTimer = null;
let lastUserActionTs = Date.now();
const AUTORELOAD_MS = 30 * 60 * 1000;
async function init() {
document.getElementById('modeLabel').textContent = AppConfig.mode;
// 通知权限请求(可延迟到首次需要时)
if ('Notification' in window) {
try {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
} catch (_) {}
}
// 禁用 Service Worker,确保不使用任何本地缓存
await unregisterServiceWorker();
bindNav();
bindReload();
bindForm();
bindAutoRefresh();
await loadMonth(current.getFullYear(), current.getMonth() + 1);
startReminderHeartbeat();
}
function bindNav() {
document.getElementById('btnPrev').addEventListener('click', async () => {
current.setMonth(current.getMonth() - 1);
await loadMonth(current.getFullYear(), current.getMonth() + 1);
});
document.getElementById('btnNext').addEventListener('click', async () => {
current.setMonth(current.getMonth() + 1);
await loadMonth(current.getFullYear(), current.getMonth() + 1);
});
}
function bindReload() {
const btn = document.getElementById('btnReload');
if (!btn) return;
btn.addEventListener('click', async () => {
lastUserActionTs = Date.now();
try {
UI.showGlobalModal('正在刷新日历...');
try { UI.appendGlobalModalLog('清空内存缓存'); } catch (_) {}
monthCache.clear();
notifiedKeys.clear();
scheduledKeys.clear();
try { UI.appendGlobalModalLog('注销 Service Worker'); } catch (_) {}
await unregisterServiceWorker();
if ('caches' in window) {
try {
UI.appendGlobalModalLog('清除浏览器缓存存储');
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
} catch (_) {}
}
} catch (_) {}
await loadMonth(current.getFullYear(), current.getMonth() + 1);
try { UI.appendGlobalModalLog('刷新流程完成'); } catch (_) {}
try { UI.completeGlobalModal('刷新完成'); } catch (_) {}
});
}
async function loadMonth(year, month) {
UI.showGlobalModal('正在查询当月数据...');
try { UI.appendGlobalModalLog('开始查询 ' + year + '-' + month + ' 数据'); } catch (_) {}
let ok = true;
console.time('loadMonthTotal');
try {
const key = `${year}-${month}`;
const cached = monthCache.get(key);
if (cached) {
monthEvents = cached;
} else {
console.time('fetchAPI');
const remote = await SyncAPI.listByMonth(year, month);
console.timeEnd('fetchAPI');
console.log('API returned events:', remote ? remote.length : 0);
monthEvents = remote;
monthCache.set(key, remote);
}
const prevY = month === 1 ? year - 1 : year;
const prevM = month === 1 ? 12 : month - 1;
const prevKey = `${prevY}-${prevM}`;
const prevCached = monthCache.get(prevKey);
let prevEvents = [];
if (prevCached) {
prevEvents = prevCached;
} else {
const prevRemote = await SyncAPI.listByMonth(prevY, prevM);
prevEvents = prevRemote;
monthCache.set(prevKey, prevRemote);
}
const merged = [];
const seen = new Set();
const pushUnique = (list) => {
list.forEach(e => {
if (!e) return;
const k = e.id ? `id:${e.id}` : `k:${e.title || ''}|${e.start || ''}|${e.end || ''}`;
if (seen.has(k)) return;
seen.add(k);
merged.push(e);
});
};
pushUnique(monthEvents);
pushUnique(prevEvents);
monthEvents = merged;
} catch (err) {
console.error('远程拉取失败', err);
try { UI.appendGlobalModalLog('错误:远程拉取失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
monthEvents = [];
alert('远程拉取失败,请稍后重试');
ok = false;
}
try { UI.appendGlobalModalLog('开始展开当月循环事件'); } catch (_) {}
console.time('expandEvents');
monthOccurrences = expandEventsForMonth(monthEvents, year, month);
console.timeEnd('expandEvents');
console.log('Expanded occurrences:', monthOccurrences.length);
try { UI.appendGlobalModalLog('展开完成,实例数=' + monthOccurrences.length); } catch (_) {}
try { UI.appendGlobalModalLog('开始渲染日历'); } catch (_) {}
console.time('renderCalendar');
// 使用 requestAnimationFrame 给 UI 线程喘息机会,避免卡死
await new Promise(r => requestAnimationFrame(() => {
UI.renderCalendar(year, month, monthOccurrences);
r();
}));
console.timeEnd('renderCalendar');
try { UI.appendGlobalModalLog('渲染完成'); } catch (_) {}
// 立即补偿提醒(捕捉刚到点或即将到点的事件)
catchUpNotifications(monthOccurrences);
// 设置未来提醒的定时器
scheduleNotifications(monthOccurrences);
// 刷新后重启心跳,确保新数据生效
startReminderHeartbeat();
prefetchAdjacentMonths(year, month);
if (ok) {
try { UI.appendGlobalModalLog('查询完成'); } catch (_) {}
try { UI.completeGlobalModal('查询完成'); } catch (_) {}
} else {
try { UI.appendGlobalModalLog('查询失败'); } catch (_) {}
try { UI.updateGlobalModalMessage('查询失败'); } catch (_) {}
}
}
// 顶层自执行函数内新增:切换提醒输入与构造循环/提醒
function updateReminderInputs() {
const type = document.getElementById('recurrenceType').value;
const enabled = document.getElementById('enableReminder').checked;
const dtWrap = document.getElementById('remindDateTime');
const tWrap = document.getElementById('remindTime');
const opts = document.getElementById('recurrenceOptions');
const wgroup = document.getElementById('weekdayGroup');
dtWrap.classList.toggle('d-none', !enabled || (type === 'DAILY' || type === 'WEEKLY'));
tWrap.classList.toggle('d-none', !enabled || (type === 'NONE'));
opts.classList.toggle('d-none', type === 'NONE');
wgroup.classList.toggle('d-none', type !== 'WEEKLY');
}
function composeRecurrence() {
const type = document.getElementById('recurrenceType').value;
if (type === 'NONE') return '';
const interval = Math.max(1, parseInt(document.getElementById('recurrenceInterval').value || '1', 10));
if (type === 'DAILY') return `DAILY;interval=${interval}`;
const checked = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]'))
.filter(el => el.checked).map(el => parseInt(el.getAttribute('data-weekday'), 10)).sort((a, b) => a - b);
const by = checked.length ? `;by=${checked.join(',')}` : '';
return `WEEKLY;interval=${interval}${by}`;
}
function combineDateAndTime(startISO, timeStr) {
const base = new Date(startISO);
const [hh, mmRaw] = (timeStr || '00:00').split(':').map(s => parseInt(s, 10));
const q = Math.round((mmRaw || 0) / 15) * 15;
const mm = Math.min(45, Math.max(0, q));
base.setHours(hh || 0, mm, 0, 0);
return base.toISOString();
}
// 可视兜底(Toast):即使系统静默通知也显示
function ensureToastContainer() {
let c = document.getElementById('toastContainer');
if (!c) {
c = document.createElement('div');
c.id = 'toastContainer';
c.className = 'toast-container';
document.body.appendChild(c);
}
return c;
}
function showToast(title, body) {
try {
const c = ensureToastContainer();
const item = document.createElement('div');
item.className = 'inapp-toast';
const strong = document.createElement('strong'); strong.textContent = title || '提醒';
const p = document.createElement('div'); p.textContent = body || '';
item.appendChild(strong); item.appendChild(p); c.appendChild(item);
setTimeout(() => { try { item.classList.add('fadeout'); } catch (_) {}
setTimeout(() => { try { c.removeChild(item); } catch (_) {} }, 300);
}, 5000);
} catch (_) {}
}
function triggerNotify(e) {
const title = '提醒:' + (e.title || '');
const body = `${new Date(e.start).toLocaleString()} - ${new Date(e.end).toLocaleString()}`;
if ('Notification' in window && Notification.permission === 'granted') {
try { new Notification(title, { body }); } catch (_) {}
}
showToast(title, body);
}
function bindForm() {
const form = document.getElementById('eventForm');
const btnReset = document.getElementById('btnReset');
const btnDelete = document.getElementById('btnDelete');
const btnSubmit = form.querySelector('[type="submit"]');
// 新增:循环与提醒输入联动
document.getElementById('recurrenceType').addEventListener('change', updateReminderInputs);
document.getElementById('enableReminder').addEventListener('change', updateReminderInputs);
updateReminderInputs();
const inputsToSnap = ['start','end','remindDateTime','remindTime'];
inputsToSnap.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
['change','blur'].forEach(evt => {
el.addEventListener(evt, () => {
try {
if (id === 'remindTime') {
const v = el.value || '';
const parts = v.split(':');
if (parts.length >= 2) {
const h = parseInt(parts[0] || '0', 10);
const mRaw = parseInt(parts[1] || '0', 10);
const m = Math.min(45, Math.max(0, Math.round(mRaw / 15) * 15));
el.value = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}
} else if (el.value) {
const d = new Date(el.value);
const q = Math.round(d.getMinutes() / 15) * 15;
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
const pad = (n) => String(n).padStart(2, '0');
el.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
} catch (_) {}
}, { passive: true });
});
});
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
// 启用原生表单校验(required 等)
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// 保存按钮进入加载态
btnSubmit.disabled = true;
const originalText = btnSubmit.textContent;
btnSubmit.textContent = '保存中...';
let data;
try {
UI.showGlobalModal('正在保存...');
try { UI.appendGlobalModalLog('收集表单数据'); } catch (_) {}
data = collectForm();
if (!data.title || !data.start) {
try { UI.appendGlobalModalLog('错误:缺少标题或开始时间'); } catch (_) {}
alert('请填写标题与开始时间');
return;
}
if (data.id) {
const base = (form.eventLoaded && form.eventLoaded.id === data.id) ? form.eventLoaded : {};
const updated = Models.normalizeForUpdate({ ...base, ...data });
try { UI.appendGlobalModalLog('准备更新事件'); } catch (_) {}
await SyncAPI.upsertEvent(updated);
} else {
const evNew = Models.createEvent(data);
try { UI.appendGlobalModalLog('准备创建新事件'); } catch (_) {}
await SyncAPI.upsertEvent(evNew);
}
// 刷新当前月
try { UI.appendGlobalModalLog('刷新当月视图'); } catch (_) {}
await loadMonth(current.getFullYear(), current.getMonth() + 1);
resetForm();
try { UI.appendGlobalModalLog('保存成功'); } catch (_) {}
try { UI.completeGlobalModal('保存成功'); } catch (_) {}
} catch (err) {
console.error('保存失败', err);
alert('保存失败:' + (err && err.message ? err.message : err));
try { UI.appendGlobalModalLog('错误:保存失败 ' + (err && err.message ? err.message : '')); } catch (_) {}
try { UI.updateGlobalModalMessage('保存失败'); } catch (_) {}
} finally {
btnSubmit.disabled = false;
btnSubmit.textContent = originalText;
}
});
btnReset.addEventListener('click', resetForm);
btnDelete.addEventListener('click', async () => {
const id = document.getElementById('eventId').value;
if (!id) return;
if (!confirm('确认删除该事件吗?')) return;
try {
UI.showGlobalModal('正在删除...');
try { UI.appendGlobalModalLog('准备删除事件 ' + id); } catch (_) {}
try { UI.appendGlobalModalLog('调用 deleteEvent'); } catch (_) {}
await SyncAPI.deleteEvent(id);
} catch (e) {
console.error('远端删除失败', e);
alert('删除失败:' + (e && e.message ? e.message : e));
try { UI.appendGlobalModalLog('错误:删除失败 ' + (e && e.message ? e.message : '')); } catch (_) {}
try { UI.updateGlobalModalMessage('删除失败'); } catch (_) {}
return;
}
try { UI.appendGlobalModalLog('删除成功,刷新当月视图'); } catch (_) {}
await loadMonth(current.getFullYear(), current.getMonth() + 1);
resetForm();
try { UI.appendGlobalModalLog('删除完成'); } catch (_) {}
try { UI.completeGlobalModal('删除完成'); } catch (_) {}
});
}
function collectForm() {
const id = document.getElementById('eventId').value || '';
const title = document.getElementById('title').value || '';
const content = document.getElementById('content').value || '';
const completed = document.getElementById('completedFlag').checked;
const startInput = document.getElementById('start').value;
const start = startInput ? new Date(startInput) : null;
const endInput = document.getElementById('end').value;
const end = endInput ? new Date(endInput) : null;
const type = document.getElementById('recurrenceType').value;
const enableReminder = document.getElementById('enableReminder').checked;
const remindDateTime = document.getElementById('remindDateTime').value;
const remindTime = document.getElementById('remindTime').value;
const recurrence = composeRecurrence();
function roundToQuarter(d) {
if (!d) return d;
const q = Math.round(d.getMinutes() / 15) * 15;
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
return d;
}
if (start) roundToQuarter(start);
if (end) roundToQuarter(end);
let remindAt = '';
if (enableReminder) {
if (type === 'NONE') {
remindAt = remindDateTime ? roundToQuarter(new Date(remindDateTime)).toISOString() : '';
} else {
remindAt = (start && remindTime) ? combineDateAndTime(start.toISOString(), remindTime) : '';
}
}
return {
id,
title,
content,
completed,
allDay: false,
start: start ? start.toISOString() : '',
end: end ? end.toISOString() : '',
remindAt,
recurrence
};
}
function resetForm() {
const form = document.getElementById('eventForm');
form.reset();
document.getElementById('eventId').value = '';
document.getElementById('btnDelete').classList.add('d-none');
document.getElementById('dayEvents').innerHTML = '';
}
function scheduleNotifications(events) {
const now = Date.now();
const next24h = now + 24 * 3600_000;
events.forEach(e => {
if (e.completed) return;
const upcoming = computeUpcomingReminders(e, now, next24h);
upcoming.forEach(remindIso => {
const ts = new Date(remindIso).getTime();
const delay = ts - now;
if (delay > 0) {
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
if (notifiedKeys.has(key) || scheduledKeys.has(key)) return;
scheduledKeys.add(key);
setTimeout(() => {
try {
if (notifiedKeys.has(key)) return;
triggerNotify({ ...e, remindAt: remindIso });
notifiedKeys.add(key);
scheduledKeys.delete(key);
} catch (_) {}
}, delay);
}
});
});
}
// 立即补偿:加载数据后,立刻检查 2 分钟窗口内的到期提醒
function catchUpNotifications(events) {
const now = Date.now();
const since = now - WINDOW_MS;
try {
events.forEach(e => {
if (e.completed) return;
const windowReminders = computeUpcomingReminders(e, since, now);
windowReminders.forEach(remindIso => {
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
if (!notifiedKeys.has(key)) {
triggerNotify({ ...e, remindAt: remindIso });
notifiedKeys.add(key);
}
});
});
} catch (_) {}
}
function startReminderHeartbeat() {
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
heartbeatTimer = setInterval(() => {
const now = Date.now();
const since = now - WINDOW_MS;
try {
monthOccurrences.forEach(e => {
if (e.completed) return;
const windowReminders = computeUpcomingReminders(e, since, now);
windowReminders.forEach(remindIso => {
const key = `${e.id}|${new Date(remindIso).toISOString()}`;
if (!notifiedKeys.has(key)) {
triggerNotify({ ...e, remindAt: remindIso });
notifiedKeys.add(key);
}
});
});
} catch (_) {}
}, 60 * 1000);
}
function bindAutoRefresh() {
['click','keydown','mousemove','touchstart','wheel'].forEach(ev => {
document.addEventListener(ev, () => { lastUserActionTs = Date.now(); }, { passive: true });
});
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
autoRefreshTimer = setInterval(() => {
if (document.hidden) return;
const idleMs = Date.now() - lastUserActionTs;
if (idleMs >= AUTORELOAD_MS) {
const y = current.getFullYear();
const m = current.getMonth() + 1;
loadMonth(y, m);
}
}, 60 * 1000);
}
async function unregisterServiceWorker() {
try {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
for (const r of regs) { try { await r.unregister(); } catch (_) {} }
}
} catch (_) {}
}
// 已停用后台同步与本地缓存
window.addEventListener('DOMContentLoaded', init);
// 不再开启后台同步
})();
function parseRecurrenceStr(str) {
const r = (str || '').trim();
if (!r) return { type: 'NONE', interval: 0, by: [] };
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
const mInt = /interval=(\d+)/.exec(r);
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
const mBy = /by=([0-9,]+)/.exec(r);
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n>=1 && n<=7) : [];
return { type, interval, by };
}
function setTimeFromIso(dateObj, timeSourceIso) {
const d = new Date(dateObj.getTime());
const t = timeSourceIso ? new Date(timeSourceIso) : null;
const hh = t ? t.getHours() : 0;
const mm = t ? t.getMinutes() : 0;
d.setHours(hh, mm, 0, 0);
return d.toISOString();
}
function expandEventsForMonth(events, year, month) {
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
const out = [];
events.forEach(e => {
if (!e || e.deleted) return;
const rec = parseRecurrenceStr(e.recurrence);
const evStart = new Date(e.start);
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
if (rec.type === 'NONE') {
out.push(e);
} else if (rec.type === 'DAILY') {
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
let cursor = new Date(from.getTime());
while (cursor <= to) {
const diffDays = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(24*3600*1000));
if (diffDays % rec.interval === 0) {
const occStartIso = setTimeFromIso(cursor, e.start);
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
}
cursor = new Date(cursor.getTime() + 24*3600*1000);
// Safety break
if (out.length > 5000) {
console.warn('Excessive recurrence detected, truncating.', e);
break;
}
}
} else if (rec.type === 'WEEKLY') {
const from = new Date(Math.max(start.getTime(), Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate())));
const to = new Date(Math.min(end.getTime(), Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate())));
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
let cursor = new Date(from.getTime());
while (cursor <= to) {
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
const weeksSinceStart = Math.floor((cursor.getTime() - Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate()))/(7*24*3600*1000));
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
const occStartIso = setTimeFromIso(cursor, e.start);
const occRemindIso = setTimeFromIso(cursor, e.remindAt || e.start);
out.push({ ...e, start: occStartIso, remindAt: occRemindIso });
}
cursor = new Date(cursor.getTime() + 24*3600*1000);
// Safety break
if (out.length > 5000) {
console.warn('Excessive recurrence detected, truncating.', e);
break;
}
}
}
});
return out;
}
function computeUpcomingReminders(e, sinceMs, untilMs) {
const rec = parseRecurrenceStr(e.recurrence);
const evStart = new Date(e.start);
const evEnd = e.end ? new Date(e.end) : new Date(evStart.getTime() + 60*60*1000);
const reminders = [];
if (rec.type === 'NONE') {
const iso = e.remindAt || e.start;
if (!iso) return reminders;
const t = new Date(iso).getTime();
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
return reminders;
}
const startDayMs = Date.UTC(evStart.getUTCFullYear(), evStart.getUTCMonth(), evStart.getUTCDate());
const endDayMs = Date.UTC(evEnd.getUTCFullYear(), evEnd.getUTCMonth(), evEnd.getUTCDate());
const windowStartDayMs = Date.UTC(new Date(sinceMs).getUTCFullYear(), new Date(sinceMs).getUTCMonth(), new Date(sinceMs).getUTCDate());
const windowEndDayMs = Date.UTC(new Date(untilMs).getUTCFullYear(), new Date(untilMs).getUTCMonth(), new Date(untilMs).getUTCDate());
const fromDay = Math.max(startDayMs, windowStartDayMs);
const toDay = Math.min(endDayMs, windowEndDayMs);
const timeSourceIso = e.remindAt || e.start;
for (let d = fromDay; d <= toDay; d += 24*3600*1000) {
const cursor = new Date(d);
if (rec.type === 'DAILY') {
const diffDays = Math.floor((d - startDayMs)/(24*3600*1000));
if (diffDays % rec.interval === 0) {
const iso = setTimeFromIso(cursor, timeSourceIso);
const t = new Date(iso).getTime();
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
}
} else if (rec.type === 'WEEKLY') {
const weekday = cursor.getUTCDay() === 0 ? 7 : cursor.getUTCDay();
const weeksSinceStart = Math.floor((d - startDayMs)/(7*24*3600*1000));
const by = rec.by.length ? rec.by : [((new Date(e.start)).getDay() || 7)];
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
const iso = setTimeFromIso(cursor, timeSourceIso);
const t = new Date(iso).getTime();
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
}
}
}
return reminders;
}
async function prefetchAdjacentMonths(year, month) {
try {
const prevY = month === 1 ? year - 1 : year;
const prevM = month === 1 ? 12 : month - 1;
const nextY = month === 12 ? year + 1 : year;
const nextM = month === 12 ? 1 : month + 1;
const pk = `${prevY}-${prevM}`;
const nk = `${nextY}-${nextM}`;
if (!monthCache.has(pk)) {
SyncAPI.listByMonth(prevY, prevM).then(d => monthCache.set(pk, d)).catch(() => {});
}
if (!monthCache.has(nk)) {
SyncAPI.listByMonth(nextY, nextM).then(d => monthCache.set(nk, d)).catch(() => {});
}
} catch (_) {}
}
@@ -0,0 +1,56 @@
// 数据模型与工具
const Models = (function () {
function uuid() {
return (crypto && crypto.randomUUID) ? crypto.randomUUID() : (
Date.now() + '-' + Math.random().toString(36).slice(2)
);
}
function nowISO() { return new Date().toISOString(); }
function ensureEnd(startISO, endISO, allDay) {
if (endISO) return endISO;
const s = new Date(startISO);
const e = new Date(s.getTime() + (allDay ? 24 * 3600_000 : 60 * 60_000));
return e.toISOString();
}
function createEvent({ title, content, start, end, allDay, remindAt, recurrence, completed }) {
const createdAt = nowISO();
const ev = {
id: uuid(),
title: (title || '').trim(),
content: (content || '').trim(),
start,
end: ensureEnd(start, end, !!allDay),
allDay: !!allDay,
remindAt: remindAt || '',
recurrence: recurrence || '',
completed: !!completed,
createdAt,
updatedAt: createdAt,
deleted: false,
};
return ev;
}
function normalizeForUpdate(ev) {
ev.updatedAt = nowISO();
if (!ev.end) ev.end = ensureEnd(ev.start, ev.end, ev.allDay);
ev.title = (ev.title || '').trim();
ev.content = (ev.content || '').trim();
ev.completed = !!ev.completed;
return ev;
}
function isSameDay(isoA, isoB) {
const a = new Date(isoA), b = new Date(isoB);
return a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate();
}
return { createEvent, normalizeForUpdate, isSameDay };
})();
window.Models = Models;
@@ -0,0 +1,85 @@
// 简单的 localStorage 心跳锁实现(兼容性回退)
// 若浏览器支持 BroadcastChannel,可替换为更即时的通讯方式
(function () {
const LOCK_KEY = 'calendar_instance_lock';
const HEARTBEAT_INTERVAL = 2000; // ms
const STALE_THRESHOLD = 5000; // ms
const instanceId = Date.now() + '-' + Math.random().toString(36).slice(2);
function now() { return Date.now(); }
function tryAcquire() {
const raw = localStorage.getItem(LOCK_KEY);
if (raw) {
try {
const obj = JSON.parse(raw);
if (now() - obj.heartbeat < STALE_THRESHOLD) {
// 有活跃实例
return false;
}
} catch (e) {
// malformed, ignore
}
}
// 申请锁
localStorage.setItem(LOCK_KEY, JSON.stringify({ id: instanceId, heartbeat: now() }));
return true;
}
function heartbeat() {
const raw = localStorage.getItem(LOCK_KEY);
if (!raw) return;
try {
const obj = JSON.parse(raw);
if (obj.id === instanceId) {
obj.heartbeat = now();
localStorage.setItem(LOCK_KEY, JSON.stringify(obj));
} else {
// 被其他实例抢占,通知并退出
alert('你好,每次只能运行一个程序。你已经运行了。');
cleanupAndExit();
}
} catch (e) { /* ignore */ }
}
function cleanupAndExit() {
try { // 尝试清理自己持有的锁
const raw = localStorage.getItem(LOCK_KEY);
if (raw) {
const obj = JSON.parse(raw);
if (obj.id === instanceId) {
localStorage.removeItem(LOCK_KEY);
}
}
} catch (e) {}
// 停止应用初始化(这里简单重载或关闭页面)
window.close && window.close();
}
if (!tryAcquire()) {
alert('你好,每次只能运行一个程序。你已经运行了。');
// 直接阻止后续初始化
throw new Error('Single instance active');
}
// 定时心跳
const hbTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
// 在页面卸载时释放锁
window.addEventListener('beforeunload', function () {
try {
const raw = localStorage.getItem(LOCK_KEY);
if (raw) {
const obj = JSON.parse(raw);
if (obj.id === instanceId) {
localStorage.removeItem(LOCK_KEY);
}
}
} catch (e) {}
clearInterval(hbTimer);
});
// ... 应用其他初始化继续 ...
})();
+62
View File
@@ -0,0 +1,62 @@
// 简易同步器:与 IIS API 交互
const SyncAPI = (function () {
const base = AppConfig.API_BASE_URL; // 例如 /Api/Events.ashx
async function listByMonth(year, month) {
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
try {
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('开始调用 listByMonth');
} catch (_) {}
const url = `${base}?year=${year}&month=${month}&_ts=${Date.now()}`;
const resp = await fetch(url, { cache: 'no-store', credentials: 'include' });
const j = await resp.json();
if (!j.ok) throw new Error(j.error || 'listByMonth failed');
try {
const count = Array.isArray(j.data) ? j.data.length : 0;
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('listByMonth 返回成功,数量=' + count);
} catch (_) {}
return j.data || [];
}
async function upsertEvent(ev) {
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
try {
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('开始调用 upsertEvent');
} catch (_) {}
const resp = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
credentials: 'include',
body: JSON.stringify(ev),
});
const j = await resp.json();
if (!j.ok) throw new Error(j.error || 'upsert failed');
try {
if (window.UI && UI.appendGlobalModalLog) UI.appendGlobalModalLog('upsertEvent 返回成功');
} catch (_) {}
}
async function deleteEvent(id) {
await (window.Auth && Auth.ensure ? Auth.ensure() : Promise.resolve());
const url = `${base}?id=${encodeURIComponent(id)}`;
try {
const resp = await fetch(url, { method: 'DELETE', credentials: 'include' });
if (resp.ok) {
const j = await resp.json();
if (!j.ok) throw new Error(j.error || 'delete failed');
return;
}
if (resp.status !== 405) {
const t = await resp.text();
throw new Error(t || 'delete failed');
}
} catch (e) {}
const resp2 = await fetch(`${url}&_method=DELETE`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, credentials: 'include', body: '' });
const j2 = await resp2.json();
if (!j2.ok) throw new Error(j2.error || 'delete failed');
}
return { listByMonth, upsertEvent, deleteEvent };
})();
window.SyncAPI = SyncAPI;
+535
View File
@@ -0,0 +1,535 @@
// UI 渲染与交互
const UI = (function () {
const gridEl = () => document.getElementById('calendar-grid');
const monthLabelEl = () => document.getElementById('currentMonth');
const dayEventsEl = () => document.getElementById('dayEvents');
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
function isMobile() {
try {
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches || (navigator && navigator.maxTouchPoints > 0);
} catch (_) { return false; }
}
// 创建详细悬停提示框
function createEventTooltip(event) {
let recurText = '';
try {
if (typeof parseRecurrenceStr === 'function') {
const rec = parseRecurrenceStr(event.recurrence || '');
if (rec && rec.type && rec.type !== 'NONE') {
const parts = [];
if (rec.type === 'DAILY') {
parts.push(rec.interval > 1 ? `每 ${rec.interval} 天` : '每天');
} else if (rec.type === 'WEEKLY') {
parts.push(rec.interval > 1 ? `每 ${rec.interval} 周` : '每周');
if (Array.isArray(rec.by) && rec.by.length) {
const map = ['一','二','三','四','五','六','日'];
const names = rec.by.map(n => map[(n - 1 + 7) % 7]).map(ch => '周' + ch);
parts.push(names.join('、'));
}
}
recurText = parts.join(',');
}
}
} catch (_) {}
const tooltip = document.createElement('div');
tooltip.className = 'event-tooltip';
tooltip.innerHTML = `
<div class="tooltip-header">
<strong>${escapeHtml(event.title || '无标题')}</strong>
${event.completed ? '<span class="badge bg-success ms-2">已完成</span>' : ''}
</div>
<div class="tooltip-content">
<table class="tooltip-table">
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
${event.content ? `<tr><th>内容:</th><td>${escapeHtmlWithLinks(event.content)}</td></tr>` : ''}
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
${recurText ? `<tr><th>循环:</th><td>${recurText}</td></tr>` : ''}
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
</table>
</div>
`;
return tooltip;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtmlWithLinks(text) {
const raw = String(text || '');
const escaped = escapeHtml(raw);
const linkPattern = /((?:https?:\/\/|\/tools\/)[^\s<]+)/g;
return escaped.replace(linkPattern, (m) => {
const href = m.startsWith('/tools/') ? m : m;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
}).replace(/\n/g, '<br>');
}
function formatDateTime(dateString) {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function renderWeekdayHeader() {
const head = document.createElement('div');
head.className = 'calendar-weekday';
WEEKDAYS.forEach(w => {
const d = document.createElement('div');
d.textContent = w;
head.appendChild(d);
});
return head;
}
function monthStart(year, month) { return new Date(Date.UTC(year, month - 1, 1)); }
function monthEnd(year, month) { return new Date(Date.UTC(year, month, 0)); }
function loadEventIntoForm(e) {
if (!e) return;
const form = document.getElementById('eventForm');
if (!form) return;
form.eventLoaded = e;
const idInput = document.getElementById('eventId');
if (idInput) idInput.value = e.id || '';
const titleInput = document.getElementById('title');
if (titleInput) titleInput.value = e.title || '';
const contentInput = document.getElementById('content');
if (contentInput) contentInput.value = e.content || '';
const completedInput = document.getElementById('completedFlag');
if (completedInput) completedInput.checked = !!e.completed;
const startInput = document.getElementById('start');
if (startInput && e.start) startInput.value = toInputValue(e.start);
const endInput = document.getElementById('end');
if (endInput && e.end) endInput.value = toInputValue(e.end);
const typeSel = document.getElementById('recurrenceType');
const intervalInput = document.getElementById('recurrenceInterval');
const weekdayInputs = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]'));
const recStr = String(e.recurrence || '');
if (typeSel) {
typeSel.value = recStr.startsWith('DAILY') ? 'DAILY' : (recStr.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
}
if (intervalInput) {
const m = /interval=(\d+)/.exec(recStr);
intervalInput.value = m ? Math.max(1, parseInt(m[1], 10)) : 1;
}
const by = (() => {
const m = /by=([0-9,]+)/.exec(recStr);
return m ? m[1].split(',').map(s => parseInt(s, 10)) : [];
})();
weekdayInputs.forEach(el => {
const v = parseInt(el.getAttribute('data-weekday'), 10);
el.checked = by.includes(v);
});
const enableReminderInput = document.getElementById('enableReminder');
const hasRemind = !!e.remindAt;
if (enableReminderInput) enableReminderInput.checked = hasRemind;
const dt = document.getElementById('remindDateTime');
const tm = document.getElementById('remindTime');
const typeVal = typeSel ? typeSel.value : 'NONE';
if (typeVal === 'NONE') {
if (dt) {
dt.classList.remove('d-none');
dt.value = e.remindAt ? toInputValue(e.remindAt) : '';
}
if (tm) tm.classList.add('d-none');
} else {
if (dt) dt.classList.add('d-none');
if (tm) {
tm.classList.remove('d-none');
tm.value = e.remindAt ? toTimeInputValue(e.remindAt) : '';
}
}
const btnDelete = document.getElementById('btnDelete');
if (btnDelete) btnDelete.classList.remove('d-none');
if (isMobile()) {
openEventModal(e);
}
}
// 在 UI.renderCalendar 的事件 pill 点击中,改为填充统一表单字段与解析循环
function renderCalendar(year, month, events) {
const grid = gridEl();
grid.innerHTML = '';
// 清理旧的星期头部,避免重复累加
Array.from(grid.parentElement.querySelectorAll('.calendar-weekday')).forEach(el => el.remove());
const head = renderWeekdayHeader();
grid.parentElement.insertBefore(head, grid);
// 性能优化:建立日期索引,避免在循环中重复遍历所有事件
const eventsByDate = new Map();
// 辅助:生成本地日期 Key YYYY-MM-DD
const toKey = (y, m, d) => `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
// 确定当月范围(本地时间)
const monthStart = new Date(year, month - 1, 1, 0, 0, 0, 0);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
const overlapsDay = (e, dateObj) => {
try {
if (!e || e.deleted || !e.start) return false;
const s = new Date(e.start);
const ep = e.end ? new Date(e.end) : new Date(e.start);
const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0);
const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999);
return s <= de && ep >= ds;
} catch (_) { return false; }
};
events.forEach(e => {
if (!e || e.deleted || !e.start) return;
try {
const s = new Date(e.start);
const end = e.end ? new Date(e.end) : new Date(e.start);
// 计算事件涉及的日期范围(截断到当月)
// 转换为本地日期的 0 点进行比较
const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate());
const eDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
// 如果事件范围与当月无交集,跳过
if (eDay < monthStart || sDay > monthEnd) return;
const iterStart = sDay < monthStart ? monthStart : sDay;
const iterEnd = eDay > monthEnd ? monthEnd : eDay;
// 遍历涉及的每一天
for (let d = new Date(iterStart); d <= iterEnd; d.setDate(d.getDate() + 1)) {
if (!overlapsDay(e, d)) continue;
const key = toKey(d.getFullYear(), d.getMonth() + 1, d.getDate());
if (!eventsByDate.has(key)) eventsByDate.set(key, []);
eventsByDate.get(key).push(e);
}
} catch (_) {}
});
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0));
const firstWeekday = start.getUTCDay();
const daysInMonth = end.getUTCDate();
// 补齐上月空白格
for (let i = 0; i < firstWeekday; i++) {
const cell = document.createElement('div');
cell.className = 'calendar-cell is-outside';
cell.innerHTML = `<div class="cell-header"><span></span></div>`;
grid.appendChild(cell);
}
const today = new Date();
const isToday = (d) => d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
const frag = document.createDocumentFragment();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day); // 本地时间对象用于 isToday 判断
const cell = document.createElement('div');
cell.className = 'calendar-cell' + (isToday(date) ? ' is-today' : '');
const header = document.createElement('div');
header.className = 'cell-header';
header.innerHTML = `<span>${day}</span>`;
const body = document.createElement('div');
body.className = 'events';
const key = toKey(year, month, day);
const esRaw = eventsByDate.get(key) || [];
const es = (function dedupDay(list) {
if (list.length <= 1) return list;
const seenDisplay = new Set();
const seenId = new Set();
return list.filter(e => {
// 优化:减少 Date 对象创建
const sStr = e.start || '';
// 简单提取时分,假设 ISO 格式
let hh = '00', mm = '00';
if (sStr.length >= 16) {
const tPart = new Date(sStr);
hh = String(tPart.getHours()).padStart(2, '0');
mm = String(tPart.getMinutes()).padStart(2, '0');
}
const title = (e.title || '').trim();
const done = e.completed ? '1' : '0';
const dk = `${title}|${hh}:${mm}|${done}`;
if (seenDisplay.has(dk)) return false;
if (e.id) {
if (seenId.has(e.id)) return false;
seenId.add(e.id);
}
seenDisplay.add(dk);
return true;
});
})(esRaw);
const MAX_PER_DAY = 50;
const visibleEs = es.slice(0, MAX_PER_DAY);
visibleEs.forEach(e => {
const pill = document.createElement('div');
pill.className = 'event-pill' + (e.completed ? ' is-done' : '');
pill.textContent = e.title;
if (!isMobile()) {
pill.addEventListener('mouseenter', () => {
const tooltip = createEventTooltip(e);
document.body.appendChild(tooltip);
const rect = pill.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.left = rect.right + 10 + 'px';
tooltip.style.top = rect.top + 'px';
tooltip.style.zIndex = '1000';
pill._currentTooltip = tooltip;
});
pill.addEventListener('mouseleave', () => {
if (pill._currentTooltip) {
document.body.removeChild(pill._currentTooltip);
pill._currentTooltip = null;
}
});
}
pill.addEventListener('click', () => {
loadEventIntoForm(e);
});
body.appendChild(pill);
});
if (es.length > MAX_PER_DAY) {
const more = document.createElement('div');
more.className = 'event-pill more';
more.textContent = `还有 ${es.length - MAX_PER_DAY} 项...`;
more.style.backgroundColor = '#f0f0f0';
more.style.color = '#888';
more.style.textAlign = 'center';
more.style.cursor = 'pointer';
more.style.fontSize = '0.85em';
body.appendChild(more);
}
cell.appendChild(header);
cell.appendChild(body);
cell.addEventListener('click', (ev) => {
const isPill = ev.target.closest('.event-pill');
if (!isPill) renderDayList(date, events);
});
frag.appendChild(cell);
}
grid.appendChild(frag);
monthLabelEl().textContent = `${year}年${month}月`;
}
function renderDayList(date, events) {
const list = dayEventsEl();
list.innerHTML = '';
function overlapsDay(e, dateObj) {
try {
if (!e || e.deleted || !e.start) return false;
const s = new Date(e.start);
const ep = e.end ? new Date(e.end) : new Date(e.start);
const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0);
const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999);
return s <= de && ep >= ds;
} catch (_) { return false; }
}
const esRaw = events.filter(e => overlapsDay(e, date) && !e.deleted);
const es = (function dedupDay(list) {
const seenId = new Set();
const seenDisplay = new Set();
function displayKey(e) {
const d = e.start ? new Date(e.start) : null;
const hh = d ? String(d.getHours()).padStart(2, '0') : '00';
const mm = d ? String(d.getMinutes()).padStart(2, '0') : '00';
const title = (e.title || '').trim();
const done = e.completed ? '1' : '0';
return `${title}|${hh}:${mm}|${done}`;
}
return list.filter(e => {
const dk = displayKey(e);
if (seenDisplay.has(dk)) return false;
if (e.id) {
if (seenId.has(e.id)) return false;
seenId.add(e.id);
}
seenDisplay.add(dk);
return true;
});
})(esRaw);
if (!es.length) {
const li = document.createElement('li');
li.className = 'list-group-item text-muted small';
li.textContent = '当天暂无事件';
list.appendChild(li);
return;
}
es.forEach(e => {
const li = document.createElement('li');
li.className = 'list-group-item';
if (e.completed) li.classList.add('is-done');
const wrap = document.createElement('div');
wrap.className = 'event-list-content';
const titleEl = document.createElement('p');
titleEl.className = 'event-list-title';
titleEl.textContent = e.title || '';
const timeEl = document.createElement('p');
timeEl.className = 'event-list-time';
const s = e.start ? new Date(e.start) : null;
const ed = e.end ? new Date(e.end) : null;
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : '';
const sTxt = fmt(s);
const eTxt = fmt(ed);
timeEl.textContent = eTxt ? `${sTxt} - ${eTxt}` : sTxt;
const descEl = document.createElement('p');
descEl.className = 'event-list-desc';
descEl.innerHTML = escapeHtmlWithLinks(e.content || '');
wrap.appendChild(titleEl);
wrap.appendChild(timeEl);
if (descEl.textContent) wrap.appendChild(descEl);
li.appendChild(wrap);
li.addEventListener('click', () => {
loadEventIntoForm(e);
});
list.appendChild(li);
});
}
function toInputValue(iso) {
const d = new Date(iso);
const q = Math.round(d.getMinutes() / 15) * 15;
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
const pad = (n) => String(n).padStart(2, '0');
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mm = pad(d.getMinutes());
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
}
function toTimeInputValue(iso) {
const d = new Date(iso);
const q = Math.round(d.getMinutes() / 15) * 15;
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function openEventModal(event) {
const backdrop = document.createElement('div');
backdrop.className = 'event-modal-backdrop';
const modal = document.createElement('div');
modal.className = 'event-modal';
const tableRows = `
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
${event.content ? `<tr><th>内容:</th><td>${escapeHtml(event.content)}</td></tr>` : ''}
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
`;
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">${escapeHtml(event.title || '无标题')}</div>
<button type="button" class="modal-close" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<table class="tooltip-table">${tableRows}</table>
</div>
`;
document.body.appendChild(backdrop);
document.body.appendChild(modal);
const close = () => { modal.remove(); backdrop.remove(); };
modal.querySelector('.modal-close').addEventListener('click', close);
backdrop.addEventListener('click', close);
}
function showGlobalModal(message) {
let backdrop = document.getElementById('globalModalBackdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.id = 'globalModalBackdrop';
backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:2000;';
const box = document.createElement('div');
box.id = 'globalModalBox';
box.style.cssText = 'min-width:260px;max-width:90vw;padding:16px 20px;border-radius:10px;background:#1f2937;color:#fff;box-shadow:0 10px 25px rgba(0,0,0,.35);text-align:center;';
const spinner = document.createElement('div');
spinner.className = 'spinner-border text-light';
spinner.setAttribute('role', 'status');
spinner.style.cssText = 'width:2rem;height:2rem;';
const msg = document.createElement('div');
msg.id = 'globalModalMessage';
msg.style.cssText = 'margin-top:10px;font-size:14px;';
msg.textContent = message || '正在处理...';
const logPanel = document.createElement('div');
logPanel.id = 'globalModalLog';
logPanel.style.cssText = 'margin-top:10px;padding:10px;background:#374151;border-radius:5px;text-align:left;font-size:12px;max-height:200px;overflow-y:auto;white-space:pre-wrap;';
const copyBtn = document.createElement('button');
copyBtn.id = 'globalModalCopy';
copyBtn.style.cssText = 'margin-top:10px;padding:5px 10px;background:#4b5563;border:none;border-radius:5px;color:#fff;cursor:pointer;';
copyBtn.textContent = '复制日志';
copyBtn.addEventListener('click', () => {
try {
const text = logPanel.textContent || '';
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => {});
}
} catch (_) {}
});
box.appendChild(spinner);
box.appendChild(msg);
box.appendChild(logPanel);
box.appendChild(copyBtn);
backdrop.appendChild(box);
document.body.appendChild(backdrop);
} else {
const msg = document.getElementById('globalModalMessage');
if (msg) msg.textContent = message || '正在处理...';
const logPanel = document.getElementById('globalModalLog');
if (logPanel) logPanel.textContent = '';
}
}
function appendGlobalModalLog(text) {
const logPanel = document.getElementById('globalModalLog');
if (!logPanel) return;
const line = new Date().toLocaleTimeString() + ' - ' + text;
logPanel.textContent = (logPanel.textContent || '') + line + '\n';
logPanel.scrollTop = logPanel.scrollHeight;
}
function hideGlobalModal() {
const backdrop = document.getElementById('globalModalBackdrop');
if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
}
function updateGlobalModalMessage(message) {
const msg = document.getElementById('globalModalMessage');
if (msg) msg.textContent = message || '';
}
function completeGlobalModal(message) {
try {
const box = document.getElementById('globalModalBox');
const spinner = box ? box.querySelector('.spinner-border') : null;
if (spinner && spinner.parentNode) spinner.parentNode.removeChild(spinner);
} catch (_) {}
updateGlobalModalMessage(message || '完成');
setTimeout(() => { try { hideGlobalModal(); } catch (_) {} }, 1000);
}
return { renderCalendar, renderDayList, toInputValue, showGlobalModal, appendGlobalModalLog, hideGlobalModal, updateGlobalModalMessage, completeGlobalModal };
})();
window.UI = UI;
+39
View File
@@ -0,0 +1,39 @@
const CACHE_NAME = 'calendar-reminder-cache-v3';
const ASSETS = [
'/WebApp/index.html',
'/WebApp/css/styles.css',
'/WebApp/css/bootstrap.local.css',
'/WebApp/js/config.js',
'/WebApp/js/db.js',
'/WebApp/js/models.js',
'/WebApp/js/ui.js',
'/WebApp/js/main.js',
'/WebApp/js/singleInstance.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)).catch(() => null)
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))))
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
event.respondWith(
caches.match(req).then((cached) => {
const fetchPromise = fetch(req).then((resp) => {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {});
return resp;
}).catch(() => cached);
return cached || fetchPromise;
})
);
});