feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// ... 应用其他初始化继续 ...
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user