feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"_comment":"移动端日历提醒鉴权配置","enable_auth":false}
|
||||
@@ -0,0 +1,906 @@
|
||||
/* ============================================
|
||||
移动端日历提醒 - 暗色主题样式
|
||||
对标 RN App (app_calendar_reminder)
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--bg: #0a0a14;
|
||||
--surface: #1a1a2e;
|
||||
--surface-variant: #252540;
|
||||
--primary: #e8b86d;
|
||||
--primary-dim: rgba(232, 184, 109, 0.15);
|
||||
--text: #f0f0f0;
|
||||
--text-secondary: #8888aa;
|
||||
--error: #ff6b6b;
|
||||
--success: #4ecdc4;
|
||||
--info: #5b9bd5;
|
||||
--border: #2a2a44;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--tab-height: 60px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 主容器:max-width 480px 居中 */
|
||||
.app-container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
视图容器
|
||||
============================================ */
|
||||
.view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(var(--tab-height) + var(--safe-bottom) + 16px);
|
||||
display: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
今日视图
|
||||
============================================ */
|
||||
.today-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
事件卡片
|
||||
============================================ */
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--surface-variant);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.event-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.event-card.completed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.event-checkbox {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-secondary);
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.event-checkbox:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.event-checkbox.checked {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.event-checkbox.checked::after {
|
||||
content: '✓';
|
||||
color: var(--bg);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.event-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.event-card.completed .event-title,
|
||||
.event-card.completed .event-content {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-meta .recurrence-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.event-delete {
|
||||
padding: 8px;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--error);
|
||||
font-size: 18px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.event-delete:active {
|
||||
opacity: 1;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
空状态
|
||||
============================================ */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 加载指示器 */
|
||||
.loading-indicator {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-indicator::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
日历视图
|
||||
============================================ */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.calendar-nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background 0.15s;
|
||||
}
|
||||
|
||||
.calendar-nav-btn:active {
|
||||
transform: scale(0.9);
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
.calendar-month-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 星期标题行 */
|
||||
.weekday-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.weekday-cell {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 日历网格 */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.day-cell:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.day-cell.today .day-number {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
background: var(--primary-dim);
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
/* 选中日期事件区 */
|
||||
.selected-date-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.selected-date-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selected-date-add {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--bg);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.selected-date-add:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.no-date-hint {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FAB 浮动按钮
|
||||
============================================ */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: calc(var(--tab-height) + var(--safe-bottom) + 20px);
|
||||
right: calc(50% - 240px + 20px);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--bg);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(232, 184, 109, 0.35);
|
||||
transition: transform 0.15s;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
/* 小屏幕时 FAB 靠右 */
|
||||
@media (max-width: 480px) {
|
||||
.fab {
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
底部 Tab 导航
|
||||
============================================ */
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: calc(var(--tab-height) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
底部滑出弹窗(事件编辑)
|
||||
============================================ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 88vh;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.modal-overlay.visible .modal-sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-save-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.modal-save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-save-btn:active:not(:disabled) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 表单字段 */
|
||||
.form-field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* 开关行 */
|
||||
.switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* iOS 风格开关 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* 重复选项按钮组 */
|
||||
.recurrence-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recurrence-btn {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.recurrence-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recurrence-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 循环间隔 */
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.interval-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.interval-btn:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.interval-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 星期勾选按钮组 */
|
||||
.weekday-options {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.weekday-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.weekday-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekday-btn:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* 时间输入 */
|
||||
input[type="datetime-local"].form-input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* 清除按钮 */
|
||||
.clear-time-btn {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.clear-time-btn:active {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
删除确认 ActionSheet
|
||||
============================================ */
|
||||
.action-sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-sheet-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.action-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 12px 16px calc(16px + var(--safe-bottom));
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.action-sheet-overlay.visible .action-sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.action-sheet-title {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-sheet-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
transition: transform 0.1s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.action-sheet-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.action-sheet-btn.danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-sheet-btn.cancel {
|
||||
background: var(--surface-variant);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Toast 提示
|
||||
============================================ */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
隐藏字段(全天时隐藏时间选择等)
|
||||
============================================ */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a0a14">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>日历提醒</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- ======== 今日视图 ======== -->
|
||||
<div id="view-today" class="view active">
|
||||
<div class="today-header">
|
||||
<div>
|
||||
<div id="greeting" class="greeting">早上好 👋</div>
|
||||
<div id="today-date" class="today-date"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div id="stat-pending" class="stat-number">0</div>
|
||||
<div class="stat-label">待完成</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div id="stat-completed" class="stat-number">0</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div id="stat-total" class="stat-number">0</div>
|
||||
<div class="stat-label">共计</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">待完成</div>
|
||||
<div id="today-loading" class="loading-indicator hidden">加载中</div>
|
||||
<div id="today-list"></div>
|
||||
<div id="today-empty" class="empty-state hidden">今天没有待办事项 🎉</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 日历视图 ======== -->
|
||||
<div id="view-calendar" class="view">
|
||||
<div class="calendar-header">
|
||||
<button id="cal-prev" class="calendar-nav-btn">‹</button>
|
||||
<div id="cal-month-title" class="calendar-month-title"></div>
|
||||
<button id="cal-next" class="calendar-nav-btn">›</button>
|
||||
</div>
|
||||
<div class="weekday-row">
|
||||
<div class="weekday-cell">日</div>
|
||||
<div class="weekday-cell">一</div>
|
||||
<div class="weekday-cell">二</div>
|
||||
<div class="weekday-cell">三</div>
|
||||
<div class="weekday-cell">四</div>
|
||||
<div class="weekday-cell">五</div>
|
||||
<div class="weekday-cell">六</div>
|
||||
</div>
|
||||
<div id="calendar-grid" class="calendar-grid"></div>
|
||||
<div id="cal-no-date" class="no-date-hint">点击日期查看事项</div>
|
||||
<div id="cal-events-section" class="hidden">
|
||||
<div class="selected-date-header">
|
||||
<div id="cal-selected-title" class="selected-date-title"></div>
|
||||
<button id="cal-add-btn" class="selected-date-add">+</button>
|
||||
</div>
|
||||
<div id="cal-events-list"></div>
|
||||
<div id="cal-events-empty" class="empty-state hidden">这一天没有事件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== FAB 浮动新增按钮 ======== -->
|
||||
<button id="fab-add" class="fab">+</button>
|
||||
|
||||
<!-- ======== 底部 Tab 导航 ======== -->
|
||||
<nav class="tab-bar">
|
||||
<button class="tab-item active" data-tab="today">
|
||||
<span class="tab-icon">☀️</span>
|
||||
<span class="tab-label">今日</span>
|
||||
</button>
|
||||
<button class="tab-item" data-tab="calendar">
|
||||
<span class="tab-icon">📅</span>
|
||||
<span class="tab-label">日历</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ======== 事件编辑弹窗 ======== -->
|
||||
<div id="modal-overlay" class="modal-overlay">
|
||||
<div class="modal-sheet">
|
||||
<div class="modal-header">
|
||||
<button id="modal-close" class="modal-close">✕</button>
|
||||
<div id="modal-title" class="modal-title">新建事件</div>
|
||||
<button id="modal-save" class="modal-save-btn">保存</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">标题</label>
|
||||
<input id="f-title" class="form-input" type="text" placeholder="输入事件标题">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">内容</label>
|
||||
<textarea id="f-content" class="form-input" placeholder="输入事件内容(可选)"></textarea>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">全天事件</span>
|
||||
<label class="toggle-switch">
|
||||
<input id="f-allday" type="checkbox">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="field-start" class="form-field">
|
||||
<label class="form-label">开始时间</label>
|
||||
<input id="f-start" class="form-input" type="datetime-local" step="900">
|
||||
</div>
|
||||
<div id="field-end" class="form-field">
|
||||
<label class="form-label">结束时间(可选,默认+1小时)</label>
|
||||
<input id="f-end" class="form-input" type="datetime-local" step="900">
|
||||
<span id="f-end-clear" class="clear-time-btn hidden">清除</span>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">启用提醒</span>
|
||||
<label class="toggle-switch">
|
||||
<input id="f-remind-enable" type="checkbox" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="field-remind" class="form-field">
|
||||
<label class="form-label">提醒时间</label>
|
||||
<input id="f-remind" class="form-input" type="datetime-local" step="900">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">重复</label>
|
||||
<div class="recurrence-options">
|
||||
<button class="recurrence-btn active" data-type="NONE">不重复</button>
|
||||
<button class="recurrence-btn" data-type="DAILY">每天</button>
|
||||
<button class="recurrence-btn" data-type="WEEKLY">每周</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="field-interval" class="form-field hidden">
|
||||
<label id="interval-label" class="form-label">每 1 天</label>
|
||||
<div class="interval-row">
|
||||
<button id="interval-minus" class="interval-btn">−</button>
|
||||
<span id="interval-value" class="interval-value">1</span>
|
||||
<button id="interval-plus" class="interval-btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="field-weekdays" class="form-field hidden">
|
||||
<label class="form-label">重复星期</label>
|
||||
<div class="weekday-options">
|
||||
<button class="weekday-btn" data-day="1">一</button>
|
||||
<button class="weekday-btn" data-day="2">二</button>
|
||||
<button class="weekday-btn" data-day="3">三</button>
|
||||
<button class="weekday-btn" data-day="4">四</button>
|
||||
<button class="weekday-btn" data-day="5">五</button>
|
||||
<button class="weekday-btn" data-day="6">六</button>
|
||||
<button class="weekday-btn" data-day="7">日</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 删除确认 ActionSheet ======== -->
|
||||
<div id="delete-sheet-overlay" class="action-sheet-overlay">
|
||||
<div class="action-sheet">
|
||||
<div class="action-sheet-title">确定要删除这个事件吗?</div>
|
||||
<button id="delete-confirm" class="action-sheet-btn danger">确认删除</button>
|
||||
<button id="delete-cancel" class="action-sheet-btn cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== Toast ======== -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,936 @@
|
||||
/* ============================================
|
||||
移动端日历提醒 - 核心逻辑
|
||||
对标 RN App (apps/app_calendar_reminder)
|
||||
纯 JS + IIFE 模块化,所有时间按北京时间处理
|
||||
============================================ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ------------------------------------------
|
||||
常量与全局状态
|
||||
------------------------------------------ */
|
||||
const API_BASE = '/api/app_calendar/events'; // 同域部署,相对路径
|
||||
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟补偿窗口
|
||||
const REQUEST_TIMEOUT = 10000; // 请求超时 10s
|
||||
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
|
||||
const state = {
|
||||
events: [], // 已加载事件(去重、过滤 deleted 后)
|
||||
currentTab: 'today', // 当前 Tab:today / calendar
|
||||
calYear: 0, // 日历当前年
|
||||
calMonth: 0, // 日历当前月(0-based)
|
||||
selectedDate: null, // 日历选中日期
|
||||
editingEvent: null, // 正在编辑的事件(null 表示新建)
|
||||
deletingEvent: null, // 待删除事件
|
||||
notifiedKeys: new Set(), // 已提醒防重复
|
||||
scheduledTimers: [], // 预调度 setTimeout 句柄
|
||||
heartbeatTimer: null, // 心跳巡检句柄
|
||||
};
|
||||
|
||||
// 编辑表单的循环规则状态
|
||||
const form = {
|
||||
recurrenceType: 'NONE', // NONE / DAILY / WEEKLY
|
||||
interval: 1,
|
||||
weekdays: [], // 1~7(1=周一,7=周日)
|
||||
};
|
||||
|
||||
// 简易选择器
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
|
||||
/* ------------------------------------------
|
||||
工具函数:日期解析与格式化
|
||||
------------------------------------------ */
|
||||
|
||||
// 安全日期解析(兼容 ISO、yyyy-MM-dd HH:mm、yyyy-MM-ddTHH:mm)
|
||||
function safeParseDate(dateStr) {
|
||||
if (!dateStr) return new Date();
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
} catch (e) { /* 忽略 */ }
|
||||
// yyyy-MM-dd HH:mm → 替换空格为 T
|
||||
if (String(dateStr).includes(' ')) {
|
||||
try {
|
||||
const d = new Date(String(dateStr).replace(' ', 'T'));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
// 兜底:仅解析日期部分
|
||||
const match = String(dateStr).match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
return new Date(parseInt(match[1], 10), parseInt(match[2], 10) - 1, parseInt(match[3], 10));
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
// 本地日期字符串 yyyy-MM-dd(北京时间)
|
||||
function formatDateStr(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
// Date → datetime-local 输入值(yyyy-MM-ddTHH:mm,本地时间)
|
||||
function toDatetimeLocalValue(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// 15 分钟粒度对齐
|
||||
function snapTo15(date) {
|
||||
const d = new Date(date);
|
||||
d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// 是否同一天
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// 事件时间展示(HH:mm)
|
||||
function formatTime(dateStr) {
|
||||
return safeParseDate(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
循环规则解析与判断(与 RN recurrence.ts 一致)
|
||||
------------------------------------------ */
|
||||
|
||||
// 解析循环规则字符串:DAILY;interval=N / WEEKLY;interval=N;by=1,3,5
|
||||
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 };
|
||||
}
|
||||
|
||||
// 循环规则中文描述(如「每2周 周一、周三」)
|
||||
function getRecurrenceLabel(recurrence) {
|
||||
if (!recurrence) return null;
|
||||
const rec = parseRecurrenceStr(recurrence);
|
||||
if (rec.type === 'DAILY') {
|
||||
return rec.interval === 1 ? '每天' : `每${rec.interval}天`;
|
||||
}
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const prefix = rec.interval === 1 ? '每周' : `每${rec.interval}周`;
|
||||
if (rec.by.length > 0) {
|
||||
return `${prefix} ${rec.by.map((d) => WEEKDAY_NAMES[d - 1] || '').join('、')}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断事件在目标日期是否展示(与 RN shouldShowEventOnDate 逻辑一致)
|
||||
function shouldShowEventOnDate(event, targetDate) {
|
||||
const evStart = safeParseDate(event.start);
|
||||
const eventDateStr = formatDateStr(evStart);
|
||||
const targetDateStr = formatDateStr(targetDate);
|
||||
|
||||
// 1. 普通事件:开始日期就是当天
|
||||
if (eventDateStr === targetDateStr) return true;
|
||||
|
||||
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||
|
||||
// 2. 持续多天事件:目标日期在 [start, end] 之间
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) return true;
|
||||
}
|
||||
|
||||
// 3. 循环事件
|
||||
const rec = parseRecurrenceStr(event.recurrence);
|
||||
if (rec.type === 'NONE') return false;
|
||||
|
||||
// 目标日期早于开始日期,不展示
|
||||
if (targetDayStart < eventStartDay) return false;
|
||||
|
||||
// 有结束日期且目标日期晚于结束日期,不展示
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart > eventEndDay) return false;
|
||||
}
|
||||
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||
return diffDays % rec.interval === 0;
|
||||
}
|
||||
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||
const by = rec.by.length ? rec.by : [(evStart.getDay() || 7)];
|
||||
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 将 timeSourceIso 的时分套用到 dateObj 上,返回 ISO 字符串
|
||||
function setTimeFromIso(dateObj, timeSourceIso) {
|
||||
const d = new Date(dateObj.getTime());
|
||||
const t = timeSourceIso ? safeParseDate(timeSourceIso) : null;
|
||||
d.setHours(t ? t.getHours() : 0, t ? t.getMinutes() : 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// 计算事件在 [sinceMs, untilMs] 时间窗内的提醒时刻(与 RN computeUpcomingReminders 一致)
|
||||
function computeUpcomingReminders(e, sinceMs, untilMs) {
|
||||
const rec = parseRecurrenceStr(e.recurrence || null);
|
||||
const evStart = safeParseDate(e.start);
|
||||
const reminders = [];
|
||||
|
||||
// 非循环事件:直接判断 remindAt 或 start
|
||||
if (rec.type === 'NONE') {
|
||||
const iso = e.remindAt || e.start;
|
||||
if (!iso) return reminders;
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
return reminders;
|
||||
}
|
||||
|
||||
const startDayMs = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate()).getTime();
|
||||
const ws = new Date(sinceMs);
|
||||
const we = new Date(untilMs);
|
||||
const windowStartDayMs = new Date(ws.getFullYear(), ws.getMonth(), ws.getDate()).getTime();
|
||||
const windowEndDayMs = new Date(we.getFullYear(), we.getMonth(), we.getDate()).getTime();
|
||||
|
||||
let endDayMs;
|
||||
if (e.end) {
|
||||
const evEnd = safeParseDate(e.end);
|
||||
endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime();
|
||||
} else {
|
||||
endDayMs = windowEndDayMs; // 无结束日期时用窗口边界,避免提前终止
|
||||
}
|
||||
|
||||
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||
const timeSourceIso = e.remindAt || e.start;
|
||||
|
||||
// 安全截断:最多遍历 400 天,防死循环
|
||||
let guard = 0;
|
||||
for (let d = fromDay; d <= toDay && guard < 400; d += 24 * 3600 * 1000, guard++) {
|
||||
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 = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay();
|
||||
const weeksSinceStart = Math.floor((d - startDayMs) / (7 * 24 * 3600 * 1000));
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(e.start).getDay() || 7)];
|
||||
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reminders;
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
API 层
|
||||
------------------------------------------ */
|
||||
|
||||
// 统一请求封装(含超时与错误处理)
|
||||
async function apiRequest(url, options) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||
try {
|
||||
const resp = await fetch(url, Object.assign({
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, options));
|
||||
if (!resp.ok) throw new Error(`服务器响应 ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
if (json && json.ok === false) throw new Error(json.error || '请求失败');
|
||||
return json;
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') throw new Error('请求超时,请稍后重试');
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// 按月查询
|
||||
async function apiGetEventsMonth(year, month) {
|
||||
const json = await apiRequest(`${API_BASE}?year=${year}&month=${month}`);
|
||||
return (json && json.data) || [];
|
||||
}
|
||||
|
||||
// 加载前 6 月 + 当月 + 后 1 月数据,按 id 去重,过滤已删除
|
||||
async function loadAllEvents() {
|
||||
const now = new Date();
|
||||
const all = [];
|
||||
const seen = new Set();
|
||||
for (let i = -6; i <= 1; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
try {
|
||||
const monthEvents = await apiGetEventsMonth(year, month);
|
||||
for (const ev of monthEvents) {
|
||||
if (!seen.has(ev.id)) {
|
||||
seen.add(ev.id);
|
||||
all.push(ev);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 单月失败不阻断整体加载
|
||||
console.error('加载月份数据失败', year, month, e);
|
||||
}
|
||||
}
|
||||
state.events = all.filter((e) => !e.deleted);
|
||||
}
|
||||
|
||||
async function apiCreateEvent(data) {
|
||||
await apiRequest(API_BASE, { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function apiUpdateEvent(id, data) {
|
||||
await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(Object.assign({}, data, { id })),
|
||||
});
|
||||
}
|
||||
|
||||
async function apiDeleteEvent(id) {
|
||||
await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
Toast 提示
|
||||
------------------------------------------ */
|
||||
let toastTimer = null;
|
||||
function showToast(msg, type) {
|
||||
const toast = $('#toast');
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast ' + (type || 'info');
|
||||
void toast.offsetWidth; // 强制 reflow 重启动画
|
||||
toast.classList.add('visible');
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
事件卡片
|
||||
------------------------------------------ */
|
||||
function createEventCard(ev) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'event-card' + (ev.completed ? ' completed' : '');
|
||||
|
||||
// 圆形 checkbox
|
||||
const checkbox = document.createElement('div');
|
||||
checkbox.className = 'event-checkbox' + (ev.completed ? ' checked' : '');
|
||||
checkbox.addEventListener('click', (e) => { e.stopPropagation(); toggleComplete(ev); });
|
||||
|
||||
// 主体(点击编辑)
|
||||
const body = document.createElement('div');
|
||||
body.className = 'event-body';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'event-title';
|
||||
title.textContent = ev.title;
|
||||
body.appendChild(title);
|
||||
|
||||
if (ev.content) {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'event-content';
|
||||
content.textContent = ev.content;
|
||||
body.appendChild(content);
|
||||
}
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'event-meta';
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.textContent = '🕐 ' + formatTime(ev.start);
|
||||
meta.appendChild(timeSpan);
|
||||
const recLabel = getRecurrenceLabel(ev.recurrence);
|
||||
if (recLabel) {
|
||||
const recSpan = document.createElement('span');
|
||||
recSpan.className = 'recurrence-label';
|
||||
recSpan.textContent = '🔁 ' + recLabel;
|
||||
meta.appendChild(recSpan);
|
||||
}
|
||||
body.appendChild(meta);
|
||||
body.addEventListener('click', () => openModal(ev));
|
||||
|
||||
// 删除按钮
|
||||
const del = document.createElement('div');
|
||||
del.className = 'event-delete';
|
||||
del.textContent = '🗑';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); openDeleteSheet(ev); });
|
||||
|
||||
card.appendChild(checkbox);
|
||||
card.appendChild(body);
|
||||
card.appendChild(del);
|
||||
return card;
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
今日视图
|
||||
------------------------------------------ */
|
||||
function renderTodayView() {
|
||||
const today = new Date();
|
||||
const hour = today.getHours();
|
||||
const greeting = hour < 12 ? '早上好' : (hour < 18 ? '下午好' : '晚上好');
|
||||
$('#greeting').textContent = greeting + ' 👋';
|
||||
$('#today-date').textContent = today.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
|
||||
const todayEvents = state.events
|
||||
.filter((ev) => shouldShowEventOnDate(ev, today))
|
||||
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
const pending = todayEvents.filter((e) => !e.completed);
|
||||
const completed = todayEvents.filter((e) => e.completed);
|
||||
|
||||
$('#stat-pending').textContent = pending.length;
|
||||
$('#stat-completed').textContent = completed.length;
|
||||
$('#stat-total').textContent = todayEvents.length;
|
||||
|
||||
const list = $('#today-list');
|
||||
list.innerHTML = '';
|
||||
pending.forEach((ev) => list.appendChild(createEventCard(ev)));
|
||||
$('#today-empty').classList.toggle('hidden', pending.length > 0);
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
日历视图
|
||||
------------------------------------------ */
|
||||
|
||||
// 生成 6x7=42 格月历数据
|
||||
function getDaysInMonth(year, month) {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const days = [];
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
|
||||
days.push({ date: new Date(year, month - 1, prevMonthLastDay - i), isCurrentMonth: false });
|
||||
}
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
||||
}
|
||||
const remaining = 42 - days.length;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function hasEventOnDate(date) {
|
||||
return state.events.some((ev) => shouldShowEventOnDate(ev, date));
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
$('#cal-month-title').textContent = `${state.calYear}年${state.calMonth + 1}月`;
|
||||
|
||||
const grid = $('#calendar-grid');
|
||||
grid.innerHTML = '';
|
||||
const days = getDaysInMonth(state.calYear, state.calMonth);
|
||||
const today = new Date();
|
||||
|
||||
days.forEach(({ date, isCurrentMonth }) => {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell';
|
||||
if (!isCurrentMonth) cell.classList.add('other-month');
|
||||
if (isSameDay(date, today)) cell.classList.add('today');
|
||||
if (state.selectedDate && isSameDay(date, state.selectedDate)) cell.classList.add('selected');
|
||||
|
||||
const num = document.createElement('div');
|
||||
num.className = 'day-number';
|
||||
num.textContent = date.getDate();
|
||||
cell.appendChild(num);
|
||||
|
||||
if (hasEventOnDate(date)) {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'event-dot';
|
||||
cell.appendChild(dot);
|
||||
}
|
||||
|
||||
cell.addEventListener('click', () => {
|
||||
state.selectedDate = date;
|
||||
renderCalendar();
|
||||
renderCalendarEvents();
|
||||
});
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
|
||||
if (state.selectedDate) {
|
||||
$('#cal-no-date').classList.add('hidden');
|
||||
$('#cal-events-section').classList.remove('hidden');
|
||||
} else {
|
||||
$('#cal-no-date').classList.remove('hidden');
|
||||
$('#cal-events-section').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendarEvents() {
|
||||
if (!state.selectedDate) return;
|
||||
const d = state.selectedDate;
|
||||
$('#cal-selected-title').textContent = d.toLocaleDateString('zh-CN', {
|
||||
month: 'long', day: 'numeric', weekday: 'long',
|
||||
});
|
||||
|
||||
const events = state.events
|
||||
.filter((ev) => shouldShowEventOnDate(ev, d))
|
||||
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
const list = $('#cal-events-list');
|
||||
list.innerHTML = '';
|
||||
events.forEach((ev) => list.appendChild(createEventCard(ev)));
|
||||
$('#cal-events-empty').classList.toggle('hidden', events.length > 0);
|
||||
}
|
||||
|
||||
function renderCurrentView() {
|
||||
if (state.currentTab === 'today') {
|
||||
renderTodayView();
|
||||
} else {
|
||||
renderCalendar();
|
||||
renderCalendarEvents();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
完成状态切换
|
||||
------------------------------------------ */
|
||||
async function toggleComplete(ev) {
|
||||
try {
|
||||
await apiUpdateEvent(ev.id, { completed: !ev.completed });
|
||||
await loadAllEvents();
|
||||
renderCurrentView();
|
||||
scheduleReminders();
|
||||
} catch (e) {
|
||||
showToast(e.message || '更新失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
事件编辑弹窗
|
||||
------------------------------------------ */
|
||||
function updateRecurrenceUI() {
|
||||
document.querySelectorAll('.recurrence-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.type === form.recurrenceType);
|
||||
});
|
||||
const isNone = form.recurrenceType === 'NONE';
|
||||
$('#field-interval').classList.toggle('hidden', isNone);
|
||||
$('#field-weekdays').classList.toggle('hidden', form.recurrenceType !== 'WEEKLY');
|
||||
$('#interval-value').textContent = form.interval;
|
||||
$('#interval-label').textContent = `每 ${form.interval} ${form.recurrenceType === 'DAILY' ? '天' : '周'}`;
|
||||
document.querySelectorAll('.weekday-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', form.weekdays.includes(parseInt(btn.dataset.day, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRemindField() {
|
||||
$('#field-remind').classList.toggle('hidden', !$('#f-remind-enable').checked);
|
||||
}
|
||||
|
||||
// 回填 / 重置表单
|
||||
function fillForm(ev) {
|
||||
if (ev) {
|
||||
$('#f-title').value = ev.title || '';
|
||||
$('#f-content').value = ev.content || '';
|
||||
$('#f-allday').checked = !!ev.allDay;
|
||||
$('#f-start').value = toDatetimeLocalValue(safeParseDate(ev.start));
|
||||
if (ev.end) {
|
||||
$('#f-end').value = toDatetimeLocalValue(safeParseDate(ev.end));
|
||||
$('#f-end-clear').classList.remove('hidden');
|
||||
} else {
|
||||
$('#f-end').value = '';
|
||||
$('#f-end-clear').classList.add('hidden');
|
||||
}
|
||||
const hasRemind = !!ev.remindAt;
|
||||
$('#f-remind-enable').checked = hasRemind;
|
||||
$('#f-remind').value = toDatetimeLocalValue(hasRemind ? safeParseDate(ev.remindAt) : snapTo15(new Date()));
|
||||
|
||||
const rec = parseRecurrenceStr(ev.recurrence);
|
||||
form.recurrenceType = rec.type === 'NONE' ? 'NONE' : rec.type;
|
||||
form.interval = rec.interval || 1;
|
||||
form.weekdays = rec.by.slice();
|
||||
} else {
|
||||
$('#f-title').value = '';
|
||||
$('#f-content').value = '';
|
||||
$('#f-allday').checked = false;
|
||||
$('#f-start').value = toDatetimeLocalValue(snapTo15(new Date()));
|
||||
$('#f-end').value = '';
|
||||
$('#f-end-clear').classList.add('hidden');
|
||||
$('#f-remind-enable').checked = true;
|
||||
$('#f-remind').value = toDatetimeLocalValue(snapTo15(new Date()));
|
||||
form.recurrenceType = 'NONE';
|
||||
form.interval = 1;
|
||||
form.weekdays = [];
|
||||
}
|
||||
toggleRemindField();
|
||||
updateRecurrenceUI();
|
||||
}
|
||||
|
||||
function openModal(ev) {
|
||||
state.editingEvent = ev || null;
|
||||
$('#modal-title').textContent = ev ? '编辑事件' : '新建事件';
|
||||
fillForm(ev);
|
||||
$('#modal-overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$('#modal-overlay').classList.remove('visible');
|
||||
state.editingEvent = null;
|
||||
}
|
||||
|
||||
// 收集表单并保存
|
||||
async function handleSave() {
|
||||
const title = $('#f-title').value.trim();
|
||||
if (!title) { showToast('请输入标题', 'error'); return; }
|
||||
|
||||
const allDay = $('#f-allday').checked;
|
||||
const startVal = $('#f-start').value;
|
||||
if (!startVal) { showToast('请设置开始时间', 'error'); return; }
|
||||
|
||||
const startDate = snapTo15(new Date(startVal));
|
||||
|
||||
// 结束时间自动补全:未设置时 +1h(全天则 +24h)
|
||||
let endDate;
|
||||
const endVal = $('#f-end').value;
|
||||
if (endVal) {
|
||||
endDate = snapTo15(new Date(endVal));
|
||||
} else {
|
||||
endDate = new Date(startDate.getTime() + (allDay ? 24 : 1) * 3600 * 1000);
|
||||
}
|
||||
|
||||
// 提醒
|
||||
let remindAt = null;
|
||||
if ($('#f-remind-enable').checked) {
|
||||
const remindVal = $('#f-remind').value;
|
||||
remindAt = remindVal ? snapTo15(new Date(remindVal)).toISOString() : startDate.toISOString();
|
||||
}
|
||||
|
||||
// 组装循环规则
|
||||
let recurrence = null;
|
||||
if (form.recurrenceType === 'DAILY') {
|
||||
recurrence = `DAILY;interval=${form.interval}`;
|
||||
} else if (form.recurrenceType === 'WEEKLY') {
|
||||
recurrence = `WEEKLY;interval=${form.interval}`;
|
||||
if (form.weekdays.length > 0) {
|
||||
recurrence += `;by=${form.weekdays.slice().sort((a, b) => a - b).join(',')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
title,
|
||||
content: $('#f-content').value.trim(),
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
allDay,
|
||||
remindAt,
|
||||
recurrence,
|
||||
completed: state.editingEvent ? !!state.editingEvent.completed : false,
|
||||
};
|
||||
|
||||
const saveBtn = $('#modal-save');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '保存中…';
|
||||
|
||||
try {
|
||||
if (state.editingEvent) {
|
||||
await apiUpdateEvent(state.editingEvent.id, eventData);
|
||||
showToast('事件已更新', 'success');
|
||||
} else {
|
||||
eventData.createdAt = new Date().toISOString();
|
||||
eventData.updatedAt = new Date().toISOString();
|
||||
eventData.deleted = false;
|
||||
await apiCreateEvent(eventData);
|
||||
showToast('事件已创建', 'success');
|
||||
}
|
||||
closeModal();
|
||||
await loadAllEvents();
|
||||
renderCurrentView();
|
||||
scheduleReminders();
|
||||
} catch (e) {
|
||||
showToast(e.message || '保存失败', 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
删除确认 ActionSheet
|
||||
------------------------------------------ */
|
||||
function openDeleteSheet(ev) {
|
||||
state.deletingEvent = ev;
|
||||
$('#delete-sheet-overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeDeleteSheet() {
|
||||
$('#delete-sheet-overlay').classList.remove('visible');
|
||||
state.deletingEvent = null;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ev = state.deletingEvent;
|
||||
if (!ev) return;
|
||||
const btn = $('#delete-confirm');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '删除中…';
|
||||
try {
|
||||
await apiDeleteEvent(ev.id);
|
||||
showToast('事件已删除', 'success');
|
||||
closeDeleteSheet();
|
||||
await loadAllEvents();
|
||||
renderCurrentView();
|
||||
scheduleReminders();
|
||||
} catch (e) {
|
||||
showToast(e.message || '删除失败', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '确认删除';
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
提醒通知
|
||||
------------------------------------------ */
|
||||
function requestNotificationPermission() {
|
||||
if (!('Notification' in window)) return;
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotify(ev) {
|
||||
// 浏览器系统通知
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification('提醒', { body: ev.title });
|
||||
} catch (e) {
|
||||
console.error('通知发送失败', e);
|
||||
}
|
||||
}
|
||||
// 前台 Toast 兜底
|
||||
showToast('提醒:' + ev.title, 'info');
|
||||
}
|
||||
|
||||
// 补偿过去 2 分钟内到期的提醒
|
||||
function catchUpNotifications(now) {
|
||||
const since = now - WINDOW_MS;
|
||||
state.events.forEach((e) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!state.notifiedKeys.has(key)) {
|
||||
triggerNotify(e);
|
||||
state.notifiedKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 预调度未来 24h 提醒 + 补偿窗口
|
||||
function scheduleReminders() {
|
||||
state.scheduledTimers.forEach((t) => clearTimeout(t));
|
||||
state.scheduledTimers = [];
|
||||
|
||||
const now = Date.now();
|
||||
const next24h = now + 24 * 3600 * 1000;
|
||||
|
||||
catchUpNotifications(now);
|
||||
|
||||
state.events.forEach((e) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||
upcoming.forEach((remindIso) => {
|
||||
const ts = safeParseDate(remindIso).getTime();
|
||||
const delay = ts - now;
|
||||
if (delay <= 0) return;
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (state.notifiedKeys.has(key)) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (!state.notifiedKeys.has(key)) {
|
||||
triggerNotify(e);
|
||||
state.notifiedKeys.add(key);
|
||||
}
|
||||
}, delay);
|
||||
state.scheduledTimers.push(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 60 秒心跳巡检,捕捉休眠/时间漂移导致的遗漏
|
||||
function startHeartbeat() {
|
||||
if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
|
||||
state.heartbeatTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const since = now - WINDOW_MS;
|
||||
state.events.forEach((e) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!state.notifiedKeys.has(key)) {
|
||||
triggerNotify(e);
|
||||
state.notifiedKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
Tab 切换
|
||||
------------------------------------------ */
|
||||
function switchTab(tab) {
|
||||
state.currentTab = tab;
|
||||
document.querySelectorAll('.tab-item').forEach((t) => {
|
||||
t.classList.toggle('active', t.dataset.tab === tab);
|
||||
});
|
||||
$('#view-today').classList.toggle('active', tab === 'today');
|
||||
$('#view-calendar').classList.toggle('active', tab === 'calendar');
|
||||
renderCurrentView();
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
事件绑定
|
||||
------------------------------------------ */
|
||||
function bindEvents() {
|
||||
// Tab 导航
|
||||
document.querySelectorAll('.tab-item').forEach((t) => {
|
||||
t.addEventListener('click', () => switchTab(t.dataset.tab));
|
||||
});
|
||||
|
||||
// FAB 新增
|
||||
$('#fab-add').addEventListener('click', () => openModal(null));
|
||||
|
||||
// 日历月份切换
|
||||
$('#cal-prev').addEventListener('click', () => {
|
||||
const d = new Date(state.calYear, state.calMonth - 1, 1);
|
||||
state.calYear = d.getFullYear();
|
||||
state.calMonth = d.getMonth();
|
||||
state.selectedDate = null;
|
||||
renderCalendar();
|
||||
});
|
||||
$('#cal-next').addEventListener('click', () => {
|
||||
const d = new Date(state.calYear, state.calMonth + 1, 1);
|
||||
state.calYear = d.getFullYear();
|
||||
state.calMonth = d.getMonth();
|
||||
state.selectedDate = null;
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
// 选中日期旁的新增按钮
|
||||
$('#cal-add-btn').addEventListener('click', () => openModal(null));
|
||||
|
||||
// 弹窗关闭 / 保存
|
||||
$('#modal-close').addEventListener('click', closeModal);
|
||||
$('#modal-save').addEventListener('click', handleSave);
|
||||
$('#modal-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === $('#modal-overlay')) closeModal();
|
||||
});
|
||||
|
||||
// 结束时间清除
|
||||
$('#f-end').addEventListener('input', () => {
|
||||
$('#f-end-clear').classList.toggle('hidden', !$('#f-end').value);
|
||||
});
|
||||
$('#f-end-clear').addEventListener('click', () => {
|
||||
$('#f-end').value = '';
|
||||
$('#f-end-clear').classList.add('hidden');
|
||||
});
|
||||
|
||||
// 提醒开关
|
||||
$('#f-remind-enable').addEventListener('change', toggleRemindField);
|
||||
|
||||
// 重复类型按钮组
|
||||
document.querySelectorAll('.recurrence-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
form.recurrenceType = btn.dataset.type;
|
||||
updateRecurrenceUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 循环间隔 +/-
|
||||
$('#interval-minus').addEventListener('click', () => {
|
||||
form.interval = Math.max(1, form.interval - 1);
|
||||
updateRecurrenceUI();
|
||||
});
|
||||
$('#interval-plus').addEventListener('click', () => {
|
||||
form.interval = form.interval + 1;
|
||||
updateRecurrenceUI();
|
||||
});
|
||||
|
||||
// 每周星期勾选
|
||||
document.querySelectorAll('.weekday-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const day = parseInt(btn.dataset.day, 10);
|
||||
if (form.weekdays.includes(day)) {
|
||||
form.weekdays = form.weekdays.filter((d) => d !== day);
|
||||
} else {
|
||||
form.weekdays = form.weekdays.concat(day).sort((a, b) => a - b);
|
||||
}
|
||||
updateRecurrenceUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 删除确认
|
||||
$('#delete-confirm').addEventListener('click', handleDelete);
|
||||
$('#delete-cancel').addEventListener('click', closeDeleteSheet);
|
||||
$('#delete-sheet-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === $('#delete-sheet-overlay')) closeDeleteSheet();
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------
|
||||
初始化
|
||||
------------------------------------------ */
|
||||
function init() {
|
||||
const now = new Date();
|
||||
state.calYear = now.getFullYear();
|
||||
state.calMonth = now.getMonth();
|
||||
|
||||
bindEvents();
|
||||
requestNotificationPermission();
|
||||
|
||||
// 初始加载数据
|
||||
$('#today-loading').classList.remove('hidden');
|
||||
loadAllEvents().then(() => {
|
||||
$('#today-loading').classList.add('hidden');
|
||||
renderCurrentView();
|
||||
scheduleReminders();
|
||||
startHeartbeat();
|
||||
}).catch((err) => {
|
||||
$('#today-loading').classList.add('hidden');
|
||||
showToast(err.message || '加载失败', 'error');
|
||||
});
|
||||
|
||||
// 回前台重新加载并调度(对应 RN 的 AppState active)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadAllEvents().then(() => {
|
||||
renderCurrentView();
|
||||
scheduleReminders();
|
||||
}).catch(() => { /* 静默失败 */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 启动
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user