feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
@@ -1,7 +1,15 @@
{
"_comment": "日程提醒工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权(需要导航/登录获得 cookie);false:完全放开访问",
"enable_auth_comment": "true:启用 tool 外层鉴权(通过九宫格导航或合法 GUID/token 进入,设置 calendar_reminder_gate cookie);false:完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新登录",
"max_age_days": 30
}
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新从九宫格或合法入口进入",
"max_age_days": 30,
"cookieName_comment": "外层 gate cookie 名,需与 src/server/index.js 中的 CALENDAR_AUTH_COOKIE 保持一致",
"cookieName": "calendar_reminder_gate",
"requireGuid_comment": "true:强制 URL 带 GUID 参数才能访问;false:允许仅凭已有 gate cookie 或导航 token 访问",
"requireGuid": false,
"guidParam_comment": "URL 中 GUID 参数名(requireGuid=true 时生效)",
"guidParam": "guid",
"guidValue_comment": "GUID 参数真值(留空表示不启用 GUID;严禁在此写真实生产 GUID,生产值来自本机私有配置)",
"guidValue": ""
}
@@ -0,0 +1,264 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>日历批量生成工具 · 独立窗口</title>
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
<link rel="stylesheet" href="css/styles.css?v=1.6">
</head>
<body class="bg-light">
<header class="navbar navbar-dark bg-warning sticky-top">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">🧺 日历批量生成工具 · 仅循环场景</span>
<div class="d-flex align-items-center gap-2">
<button id="btnBackToMain" class="btn btn-light btn-sm" title="回到日历主界面">🗓 返回主日历</button>
<button id="btnReload" class="btn btn-light btn-sm" title="刷新批次列表">⟳ 刷新</button>
</div>
</div>
</header>
<main class="container py-3" style="max-width:1280px">
<ul class="nav nav-tabs mb-3" id="batchTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-generator" data-bs-toggle="tab" data-bs-target="#panel-generator" type="button">生成器</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-batches" data-bs-toggle="tab" data-bs-target="#panel-batches" type="button" onclick="CalendarBatchTool && CalendarBatchTool.loadBatches && CalendarBatchTool.loadBatches()">批次管理</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-sql" data-bs-toggle="tab" data-bs-target="#panel-sql" type="button">🧪 SQL 执行</button>
</li>
</ul>
<div class="tab-content" id="batchTabsContent">
<div class="tab-pane fade show active" id="panel-generator" role="tabpanel">
<form id="batchForm" class="vstack gap-3">
<div class="row g-2">
<div class="col-8">
<label class="form-label">批次名称(可选)</label>
<input type="text" id="batch_name" class="form-control" placeholder="如:2026Q4 洗被子提醒">
</div>
<div class="col-4">
<label class="form-label">循环模式</label>
<select id="batch_mode" class="form-select">
<option value="monthlyByDay" selected>每月 N 号(推荐)</option>
<option value="monthlyByNthWeekday">每月第 N 周星期 X</option>
<option value="weekly">每 N 周 · 星期多选</option>
<option value="daily">每 N 天</option>
</select>
</div>
</div>
<div class="row g-2">
<div class="col-3"><label class="form-label">时间范围 · 起</label><input type="date" id="batch_rangeStart" class="form-control" required></div>
<div class="col-3"><label class="form-label">时间范围 · 止</label><input type="date" id="batch_rangeEnd" class="form-control" required></div>
<div class="col-6 d-flex align-items-end gap-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_allDay">
<label class="form-check-label" for="batch_allDay">全天(忽略时分)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_skipExternal">
<label class="form-check-label" for="batch_skipExternal">不联动周报下游</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_completed">
<label class="form-check-label" for="batch_completed">全部标记已完成</label>
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="daily">
<div class="row g-2"><div class="col-4">
<label class="form-label">每 _ 天</label>
<input type="number" min="1" value="1" id="batch_dailyEvery" class="form-control">
</div></div>
</div>
<div class="mode-fieldset" data-mode="weekly" style="display:none">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 周</label><input type="number" min="1" value="1" id="batch_weeklyEvery" class="form-control"></div>
<div class="col-8">
<label class="form-label">星期(可多选)</label>
<div id="batch_weekdays" class="hstack gap-3 flex-wrap">
<label class="form-check"><input type="checkbox" value="0" class="form-check-input batch-wd">周日</label>
<label class="form-check"><input type="checkbox" value="1" class="form-check-input batch-wd">周一</label>
<label class="form-check"><input type="checkbox" value="2" class="form-check-input batch-wd">周二</label>
<label class="form-check"><input type="checkbox" value="3" class="form-check-input batch-wd">周三</label>
<label class="form-check"><input type="checkbox" value="4" class="form-check-input batch-wd">周四</label>
<label class="form-check"><input type="checkbox" value="5" class="form-check-input batch-wd">周五</label>
<label class="form-check"><input type="checkbox" value="6" class="form-check-input batch-wd">周六</label>
</div>
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="monthlyByDay">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="2" id="batch_monthlyEvery" class="form-control"></div>
<div class="col-8">
<label class="form-label">每月哪些号(支持 1-5,15,25-28 混合写法)</label>
<input type="text" id="batch_monthlyDays" class="form-control" value="1-5" placeholder="例如:1-5,15,25-28">
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="monthlyByNthWeekday" style="display:none">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="1" id="batch_monthlyEvery2" class="form-control"></div>
<div class="col-4"><label class="form-label">第几周</label>
<select id="batch_nthWeek" class="form-select"><option>1</option><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
</div>
<div class="col-4"><label class="form-label">星期</label>
<select id="batch_nthWeekday" class="form-select">
<option value="0">周日</option><option value="1">周一</option><option value="2">周二</option>
<option value="3">周三</option><option value="4">周四</option><option value="5">周五</option><option value="6">周六</option>
</select>
</div>
</div>
</div>
<div class="row g-2">
<div class="col-4"><label class="form-label">开始时分</label><input type="time" id="batch_startTime" class="form-control" value="09:00"></div>
<div class="col-4"><label class="form-label">结束时分(留空=不设)</label><input type="time" id="batch_endTime" class="form-control" value="10:00"></div>
<div class="col-4"><label class="form-label">提醒时分(留空=不提醒)</label><input type="time" id="batch_remindTime" class="form-control" value="08:30"></div>
</div>
<div>
<label class="form-label">标题模板(支持 {date} {yyyy} {MM} {dd} {W})</label>
<input type="text" id="batch_title" class="form-control" value="洗被子提醒({MM}-{dd})" required maxlength="150" placeholder="如:洗被子提醒({MM}-{dd})">
</div>
<div>
<label class="form-label">内容模板(同上占位符)</label>
<textarea id="batch_content" class="form-control" rows="3" placeholder="写备注,支持 {date} 占位符"></textarea>
</div>
<div class="hstack gap-2">
<button type="button" id="batch_btnPreview" class="btn btn-outline-primary">👁 预览 SQL</button>
<button type="button" id="batch_btnApply" class="btn btn-primary" disabled>▶ 直接入库</button>
<button type="button" id="batch_btnDownload" class="btn btn-outline-secondary" disabled>💾 下载 SQL</button>
<span id="batch_previewHint" class="text-muted small ms-2">请先点「预览 SQL」再入库</span>
</div>
</form>
<div id="batch_previewArea" class="mt-3 d-none">
<div class="card">
<div class="card-header small">预览信息</div>
<div class="card-body small">
<div id="batch_previewStats" class="mb-2"></div>
<div class="mb-2"><span class="text-danger fw-bold" id="batch_preExisting"></span></div>
<div class="mb-2"><strong>样例(前 20 条):</strong></div>
<table class="table table-sm table-hover" id="batch_sampleTable"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th></tr></thead><tbody></tbody></table>
<div class="mb-2"><strong>SQL(前 503 行,完整的请下载文件):</strong></div>
<textarea id="batch_sqlText" readonly rows="14" class="form-control font-monospace small"></textarea>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="panel-batches" role="tabpanel">
<div class="d-flex justify-content-between mb-2">
<div style="width:360px"><input id="batch_search" class="form-control form-control-sm" placeholder="搜索标题 / 模式…(输入即搜)"></div>
<button type="button" id="batch_refresh" class="btn btn-outline-secondary btn-sm">⟳ 刷新批次</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="batch_listTable">
<thead><tr>
<th>批次名</th><th>模式</th><th>时间范围</th>
<th>生成 / 入库 / 去重 / 已回滚</th>
<th>状态</th><th>创建时间</th><th>操作</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="panel-sql" role="tabpanel">
<form class="vstack gap-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">批次名称(必填,便于后面批次管理/回滚查找)</label>
<input type="text" id="sql_batch_name" class="form-control" placeholder="如:2026-09-02 手动导入客户提醒 SQL">
</div>
<div class="col-6 d-flex align-items-end gap-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="sql_skipExternal" checked>
<label class="form-check-label" for="sql_skipExternal">不联动周报下游(默认勾,避免爆量)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="sql_completed">
<label class="form-check-label" for="sql_completed">全部标记已完成</label>
</div>
</div>
</div>
<div>
<label class="form-label">SQL 文本框(只支持 <code>INSERT INTO events (...) VALUES (...)</code>;单条 / 多条 / 多个语句用 ; 分开都行;每行 events 字段允许:id/title/content/start/end/allDay/remindAt/recurrence/createdAt/updatedAt/completed/deleted/batch_id/completedAt/deletedAt,其他字段一律被拒绝;禁止 UPDATE / DELETE / DROP / ALTER)</label>
<textarea id="sql_textarea" rows="22" class="form-control font-monospace small" placeholder="INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES
('客户A回访','准备季度会议','2026-09-03 09:00','2026-09-03 10:00',0,'2026-09-03 08:30',0,0,NULL),
('客户B回访','报价最终版本','2026-09-05 14:00','2026-09-05 15:00',0,'2026-09-05 13:30',0,0,NULL);
"></textarea>
</div>
<div class="hstack gap-2">
<button type="button" id="sql_btnCheck" class="btn btn-outline-info">🧪 SQL 语句检查</button>
<button type="button" id="sql_btnApply" class="btn btn-warning" disabled>▶ 执行 SQL 入库(生成批次,可回滚)</button>
<button type="button" id="sql_btnDownload" class="btn btn-outline-secondary">💾 下载 SQL</button>
<span id="sql_hint" class="text-muted small ms-2">建议先点「检查」确认语法/重复后再执行</span>
</div>
<div id="sql_checkArea" class="d-none mt-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>🧪 检查结果</span>
<span id="sql_checkSummary" class="badge bg-light"></span>
</div>
<div class="card-body small">
<div id="sql_checkMeta" class="mb-2"></div>
<div id="sql_checkForbidden" class="d-none text-danger mb-2"></div>
<div id="sql_checkSyntaxErrors" class="d-none text-danger mb-2"></div>
<div id="sql_checkColErrors" class="d-none text-danger mb-2"></div>
<div id="sql_checkDuplicate" class="d-none text-warning mb-2">
<span class="fw-bold">⚠️ 检测到重复(宽松模式):</span>
<button id="sql_dupDetail" class="btn btn-outline-warning btn-sm ms-2">查看重复明细…</button>
</div>
<div><strong>样例行(前 15 条):</strong></div>
<div class="table-responsive">
<table class="table table-sm table-hover"><thead><tr><th>语句#</th><th>行#</th><th>标题</th><th>开始</th><th>结束</th></tr></thead><tbody id="sql_checkSampleTbody"></tbody></table>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</main>
<div class="modal fade" id="batchDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">批次详情</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div id="batch_detailBody"></div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div>
</div>
</div>
</div>
<div class="modal fade" id="sqlDuplicateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">⚠️ SQL 批次检测到重复(宽松模式)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small">
<div id="sql_dupSummary" class="mb-2"></div>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead><tr><th>你的 SQL 语句#-行#</th><th>你的标题</th><th>你的开始</th><th>匹配库内事件标题</th><th>匹配库内开始</th><th>相似度</th><th>操作</th></tr></thead>
<tbody id="sql_dupTbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="sql_dupCancel">先不执行,回去改 SQL</button>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal" id="sql_dupConfirm">我已知悉重复,继续执行生成批次</button>
</div>
</div>
</div>
</div>
<script src="js/config.js"></script>
<script src="/lib/bootstrap.bundle.min.js"></script>
<script src="js/batch.js?v=1.7"></script>
</body>
</html>
-52
View File
@@ -1,52 +0,0 @@
*{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}
@@ -678,3 +678,25 @@ main {
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Batch tool (批量生成) */
#btnOpenBatchTool { font-weight: 600; }
.mode-fieldset {
border: 1px solid #e9ecef;
border-radius: 10px;
padding: 12px 14px 6px;
background: #fbfcfd;
}
#batch_sqlText {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre;
background: #1f2937;
color: #f8fafc;
border-color: #111827;
}
#batch_sampleTable { font-size: 12px; }
#batch_listTable .badge { font-size: 11px; }
.batch-detail-diag { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; }
+4 -3
View File
@@ -6,8 +6,8 @@
<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">
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
<link rel="stylesheet" href="css/styles.css?v=1.6">
<script src="js/background.js?v=1.1" defer></script>
</head>
<body>
@@ -19,6 +19,7 @@
<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>
<button id="btnOpenBatchTool" class="btn btn-warning btn-sm" title="批量生成循环提醒(展开到每天)· 新窗口打开" target="_blank" rel="noopener">🧺 批量生成</button>
</div>
</div>
</header>
@@ -127,11 +128,11 @@
</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/open_batch.js?v=1.6"></script>
<script src="js/main.js?v=1.3"></script>
<script src="/lib/bootstrap.bundle.min.js"></script>
</body>
+495
View File
@@ -0,0 +1,495 @@
(function () {
const $ = (id) => document.getElementById(id)
const api = (path, opts = {}) => fetch(`/api/calendar_reminder/${path.replace(/^\//, '')}`, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts, { body: (opts.body && typeof opts.body !== 'string') ? JSON.stringify(opts.body) : opts.body }))
const copy = async (text) => {
try { await navigator.clipboard.writeText(text); return true } catch { return false }
}
const setBtn = (el, loading, text) => { if (!el) return; if (loading) { el.disabled = true; el.dataset.old = el.textContent; el.textContent = text || el.textContent + ' 中…' } else { el.disabled = false; if (el.dataset.old) el.textContent = el.dataset.old } }
const toastNode = (msg, type) => {
if (typeof msg !== 'string' || !msg) return
const div = document.createElement('div')
const colorMap = { success: '#d1e7dd', error: '#f8d7da', info: '#cff4fc', warn: '#fff3cd' }
const fgMap = { success: '#0f5132', error: '#842029', info: '#055160', warn: '#664d03' }
const bg = colorMap[type || 'info'] || colorMap.info
const fg = fgMap[type || 'info'] || fgMap.info
div.textContent = msg
div.style.cssText = `position:fixed;top:64px;right:24px;z-index:99999;padding:8px 14px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,0.18);background:${bg};color:${fg};font-size:13px;max-width:420px;animation:cbFadeIn 0.18s ease-out;`
if (!document.getElementById('cbToastStyles')) {
const s = document.createElement('style'); s.id = 'cbToastStyles'
s.textContent = `@keyframes cbFadeIn { from { transform: translateY(-6px); opacity: 0;} to { transform: translateY(0); opacity:1; } }`
document.head.appendChild(s)
}
document.body.appendChild(div)
setTimeout(() => { div.style.transition = 'opacity 0.2s'; div.style.opacity = '0'; setTimeout(() => div.remove(), 240) }, 2200)
}
const toast = (msg, type) => {
try {
if (window.CAL && window.CAL.toast) window.CAL.toast(msg, type)
else toastNode(msg, type)
} catch { toastNode(msg, type) }
}
const readForm = () => {
const mode = $('batch_mode').value
const weekdays = [...document.querySelectorAll('.batch-wd:checked')].map(c => Number(c.value))
const params = {
batchName: $('batch_name').value.trim(),
mode,
rangeStart: $('batch_rangeStart').value,
rangeEnd: $('batch_rangeEnd').value,
allDay: $('batch_allDay').checked,
completed: $('batch_completed').checked,
startTime: $('batch_startTime').value || '',
endTime: $('batch_endTime').value || '',
remindTime: $('batch_remindTime').value || '',
title: $('batch_title').value.trim(),
content: $('batch_content').value
}
if (mode === 'daily') params.dailyEvery = Number($('batch_dailyEvery').value || 1)
if (mode === 'weekly') { params.weeklyEvery = Number($('batch_weeklyEvery').value || 1); params.weekdays = weekdays }
if (mode === 'monthlyByDay') { params.monthlyEvery = Number($('batch_monthlyEvery').value || 1); params.monthlyDays = $('batch_monthlyDays').value.trim() }
if (mode === 'monthlyByNthWeekday') { params.monthlyEvery2 = Number($('batch_monthlyEvery2').value || 1); params.nthWeek = Number($('batch_nthWeek').value || 3); params.nthWeekday = Number($('batch_nthWeekday').value || 0) }
return params
}
const fillForm = (params) => {
if (!params) return
const p = Object.assign({
mode: 'monthlyByDay', rangeStart: '', rangeEnd: '', allDay: false, completed: false,
startTime: '09:00', endTime: '10:00', remindTime: '08:30', title: '', content: ''
}, params || {})
$('batch_name').value = p.batchName || ''
$('batch_mode').value = p.mode
$('batch_rangeStart').value = p.rangeStart
$('batch_rangeEnd').value = p.rangeEnd
$('batch_allDay').checked = !!p.allDay
$('batch_completed').checked = !!p.completed
$('batch_startTime').value = p.startTime
$('batch_endTime').value = p.endTime
$('batch_remindTime').value = p.remindTime
$('batch_title').value = p.title
$('batch_content').value = p.content
$('batch_dailyEvery').value = p.dailyEvery || 1
$('batch_weeklyEvery').value = p.weeklyEvery || 1
if (Array.isArray(p.weekdays)) document.querySelectorAll('.batch-wd').forEach(cb => { cb.checked = p.weekdays.includes(Number(cb.value)) })
$('batch_monthlyEvery').value = p.monthlyEvery || 2
$('batch_monthlyDays').value = p.monthlyDays || '1-5'
$('batch_monthlyEvery2').value = p.monthlyEvery2 || 1
$('batch_nthWeek').value = p.nthWeek || 3
$('batch_nthWeekday').value = String(p.nthWeekday ?? 0)
refreshModeVisibility()
$('batch_btnApply').disabled = true
$('batch_btnDownload').disabled = true
$('batch_previewArea').classList.add('d-none')
}
const refreshModeVisibility = () => {
const mode = $('batch_mode').value
document.querySelectorAll('.mode-fieldset').forEach(el => {
el.style.display = (el.dataset.mode === mode) ? '' : 'none'
})
}
let latestPreview = null
const onPreview = async () => {
const params = readForm()
if (!params.rangeStart || !params.rangeEnd) return toast('请选择时间范围', 'error')
if (!params.title) return toast('请填写标题模板', 'error')
if (params.mode === 'weekly' && (!params.weekdays || !params.weekdays.length)) return toast('每周模式请至少选一个星期几', 'error')
if (params.mode === 'monthlyByDay' && !params.monthlyDays) return toast('请填写每月日期表达式(如 1-5)', 'error')
const btn = $('batch_btnPreview')
setBtn(btn, true, '预览中…')
try {
const r = await api('/batch/preview', { method: 'POST', body: params })
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'preview failed')
latestPreview = data
$('batch_previewArea').classList.remove('d-none')
const stats = `预生成:<b>${data.generated}</b> 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = <code>${data.batchId}</code>`
$('batch_previewStats').innerHTML = stats
if (data.pre_existing_count) {
$('batch_preExisting').textContent = `⚠ 其中 ${data.pre_existing_count} 条 (title, start) 已存在,入库时将命中去重并复用原 id(不会真插入新行)`
} else { $('batch_preExisting').textContent = '' }
const tb = $('batch_sampleTable').querySelector('tbody')
tb.innerHTML = ''
;(data.sample_list || []).forEach(s => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td>`
tb.appendChild(tr)
})
$('batch_sqlText').value = data.sql_display + (data.truncated ? '\n... 超过 500 行,完整 SQL 请点下载 SQL 文件按钮' : '')
$('batch_btnApply').disabled = false
$('batch_btnDownload').disabled = false
} catch (e) {
toast('预览失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onApply = async () => {
if (!latestPreview) return toast('请先点「预览 SQL」', 'error')
const ok = confirm(`确认将 ${latestPreview.generated} 条事件直接写入数据库?\n(可在批次管理里一键回滚逻辑删除)`)
if (!ok) return
const btn = $('batch_btnApply')
setBtn(btn, true, '入库中…')
try {
const r = await api('/batch/apply', {
method: 'POST',
body: { batch_id: latestPreview.batchId, params: latestPreview.params_saved, sql_full: latestPreview.sql_full, skip_external_sync: $('batch_skipExternal').checked }
})
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'apply failed')
toast(`写入成功:生成${data.generated} 入库${data.inserted} 去重${data.dedup} 预重${data.pre_existing}`, 'success')
loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('入库失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onDownload = () => {
if (!latestPreview) return
const blob = new Blob([latestPreview.sql_full], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_batch_preview_${latestPreview.batchId}.sql`
a.click()
URL.revokeObjectURL(a.href)
}
/* ---------- SQL Manual Tab ---------- */
let _sqlLatestReport = null
let _sqlConfirmDup = false
let _sqlConfirmForbidden = false
const pad2 = (n) => String(n).padStart(2, '0')
const tsFile = () => {
const d = new Date()
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
}
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[ch]))
const sqlReadForm = () => ({
batch_name: ($('sql_batch_name') || {}).value || '',
skip_external_sync: !!($('sql_skipExternal') || {}).checked,
mark_completed: !!($('sql_completed') || {}).checked,
sql_text: ($('sql_textarea') || {}).value || ''
})
const sqlRenderReport = (data) => {
_sqlLatestReport = data || null
const area = $('sql_checkArea')
if (!area) return
if (!_sqlLatestReport || !data) { area.classList.add('d-none'); return }
area.classList.remove('d-none')
const total = data.total_statements || 0
const ins = data.insert_statements || 0
const rows = data.total_rows || 0
const ok = !!data.ok
$('sql_checkSummary').textContent = `${ok ? '✅ 通过' : '❌ 未通过'} · ${total} 语句 · ${ins} INSERT · ${rows} 行`
$('sql_checkSummary').className = `badge ${ok ? 'bg-success' : 'bg-danger'}`
// Meta
$('sql_checkMeta').innerHTML = `
阈值:重复相似度 ≥ <b>${data.duplicate_threshold || 0.65}</b>,最多 <b>${data.max_rows_limit || 500}</b> 条,执行超时 <b>${Math.round((data.max_ms_limit || 60000)/1000)}s</b>。<br>
检测到 <b>${data.pre_existing_count || 0}</b> 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)<br>
检测到 <b>${data.high_confidence_duplicates || 0}</b> 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:<b>${data.range_start || '未知起'}</b> ~ <b>${data.range_end || '未知止'}</b>
`
// Forbidden / syntax / col errors
const renderList = (containerId, headerText, list, color) => {
const el = $(containerId)
if (!el) return
if (!list || !list.length) { el.classList.add('d-none'); return }
el.classList.remove('d-none')
const items = list.map((e, idx) => `<li><b>#${idx+1}</b> [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `<br><small class="text-muted">${escapeHtml(e.preview)}</small>` : ''}${e.title ? `<br><small>title=${escapeHtml(e.title)}</small>` : ''}</li>`).join('')
el.innerHTML = `<div class="fw-bold mb-1" style="color:${color}">${headerText}</div><ul style="padding-left:1.25rem">${items}</ul>`
}
renderList('sql_checkForbidden', '❌ 语句类型被拒绝(只允许 INSERT INTO events):', data.forbidden, '#dc3545')
renderList('sql_checkSyntaxErrors', '❌ 语法错误(SQLite EXPLAIN 未通过):', data.syntax_errors, '#dc3545')
renderList('sql_checkColErrors', '❌ 列 / 必填 / 时间格式错误:', data.col_errors, '#dc3545')
// Duplicates
const dupEl = $('sql_checkDuplicate')
if (dupEl) {
if ((data.high_confidence_duplicates || 0) === 0) dupEl.classList.add('d-none')
else {
dupEl.classList.remove('d-none')
dupEl.querySelector('span.fw-bold').textContent = `⚠️ 检测到 ${data.high_confidence_duplicates} 条宽松匹配高置信重复(≥${data.duplicate_threshold||0.65}):`
}
}
// Sample rows
const tb = $('sql_checkSampleTbody')
if (tb) {
tb.innerHTML = ''
const meta = (data.row_meta || []).slice(0, 15)
if (!meta.length) tb.innerHTML = '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'
else meta.forEach(r => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${r.stmt_idx ?? ''}</td><td>${r.row_idx ?? ''}</td><td>${escapeHtml(r.title || '')}</td><td>${escapeHtml(r.start || '')}</td><td>${escapeHtml(r.end || '')}</td>`
tb.appendChild(tr)
})
}
// 执行按钮:允许状态
const applyBtn = $('sql_btnApply')
if (applyBtn) applyBtn.disabled = false
}
const sqlOpenDuplicateModal = () => {
if (!_sqlLatestReport || !_sqlLatestReport.high_confidence_duplicates) return
const rows = (_sqlLatestReport.duplicates || []).filter(d => (d.score || 0) >= (_sqlLatestReport.duplicate_threshold || 0.65) && d.match && d.match.length)
if (!rows.length) return toast('没有高置信重复明细', 'warn')
$('sql_dupSummary').innerHTML = `共 <b>${rows.length}</b> 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
const tb = $('sql_dupTbody')
tb.innerHTML = ''
rows.forEach(d => {
const mainMatch = (d.match || [])[0] || {}
const tr = document.createElement('tr')
tr.innerHTML = `
<td>${d.stmt_idx ?? ''}-${d.row_idx ?? ''}</td>
<td>${escapeHtml(d.title_left || '')}</td>
<td>${escapeHtml(d.start_left || '')}</td>
<td>${escapeHtml(mainMatch.title || '')}</td>
<td>${escapeHtml(mainMatch.start || '')}</td>
<td>${Number(d.score || 0).toFixed(2)}</td>
<td><a href="index.html" target="_blank" class="btn btn-sm btn-outline-primary">打开主日历核对</a></td>`
tb.appendChild(tr)
})
const m = bootstrap.Modal.getOrCreateInstance($('sqlDuplicateModal'))
m.show()
}
const sqlOnCheck = async () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const btn = $('sql_btnCheck')
setBtn(btn, true, '检查中…')
try {
const r = await api('/batch_sql_check', { method: 'POST', body: f })
const data = await r.json()
sqlRenderReport(data)
_sqlConfirmDup = (data.high_confidence_duplicates || 0) === 0
_sqlConfirmForbidden = !!(data.ok)
if (data.ok) toast(`检查通过:${data.insert_statements||0} INSERT / ${data.total_rows||0} 行 / ${data.high_confidence_duplicates||0} 宽松重复`, 'success')
else toast(`检查未通过:${(data.forbidden||[]).length + (data.syntax_errors||[]).length + (data.col_errors||[]).length} 处错误`, 'error')
} catch (e) {
toast('检查失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnApply = async () => {
if (!_sqlLatestReport) return toast('请先点「SQL 语句检查」', 'error')
const f = sqlReadForm()
if (!f.batch_name.trim()) return toast('请填写批次名称(便于回滚时查找)', 'error')
// 先把最新状态再拉一遍(用户可能中间改过 textarea,不允许跳检查直接执行)
const checkBtn = $('sql_btnCheck')
if (checkBtn && checkBtn.disabled !== true) {
// 如果检查按钮不是 loading,强制再跑一次检查,避免用户中间改了 SQL 直接点执行
await sqlOnCheck()
if (!_sqlLatestReport) return
}
const hasErr = ((_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length) > 0
if (hasErr && !_sqlConfirmForbidden) {
const okForb = confirm(`有 ${(_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length} 处语法/列/语句类型被拒绝错误。真的要强制继续执行?(大概率会批量 INSERT 失败;建议先改 SQL 到检查通过)`)
if (!okForb) return
_sqlConfirmForbidden = true
}
const dupCount = _sqlLatestReport.high_confidence_duplicates || 0
if (dupCount > 0 && !_sqlConfirmDup) {
// 先弹重复详情 Modal,用户点 Modal 里的「我已知悉」再真正执行
sqlOpenDuplicateModal()
return
}
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: _sqlConfirmForbidden })
}
const sqlReallyApply = async (extra) => {
const f = Object.assign(sqlReadForm(), extra || {})
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
if (!f.batch_name.trim()) return toast('请填写批次名称', 'error')
const btn = $('sql_btnApply')
setBtn(btn, true, '执行入库中…')
try {
const r = await api('/batch_sql_apply', { method: 'POST', body: f })
const data = await r.json()
if (!r.ok || !data.ok) {
if (r.status === 409 && data.report) {
// 后端强制拦:需要 confirm_forbidden / confirm_duplicates
sqlRenderReport(data.report)
if (data.error && String(data.error).includes('duplicates_need_confirm')) { sqlOpenDuplicateModal(); return }
if (data.error && String(data.error).includes('forbidden_or_error_need_confirm')) {
const ok = confirm('后端强制拦截:有错误语句 / 不允许的列。点确定 = 我已知悉,仍尝试执行(可能全部或部分失败)')
if (!ok) return
_sqlConfirmForbidden = true
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: true })
return
}
}
throw new Error(data.error || `HTTP ${r.status}`)
}
toast(`SQL 批次执行成功:生成${data.generated} / 入库${data.inserted} / 去重${data.dedup} / 宽松重复${data.high_confidence_duplicates||0}。batch_id=${data.batch_id}`, 'success')
_sqlConfirmDup = false
_sqlConfirmForbidden = false
await loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('SQL 批次执行失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnDownload = () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const head = `-- 下载时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n-- 批次名:${f.batch_name || '(未命名)'}\n-- 标记:${f.skip_external_sync ? '不联动下游 ' : ''}${f.mark_completed ? '全部已完成' : ''}\n-- 注意:仅支持 INSERT INTO events(...)\n\n`
const blob = new Blob(['\ufeff' + head + f.sql_text], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_events_sql_${tsFile()}.sql`
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 1500)
toast('SQL 已下载(UTF-8 BOM,避免中文乱码)', 'info')
}
const badgeFor = (st) => {
const map = { draft: 'bg-secondary', inserting: 'bg-primary', inserted: 'bg-success', rolled_back: 'bg-dark', partial: 'bg-warning' }
return `<span class="badge ${map[st] || 'bg-light text-dark'}">${st}</span>`
}
let batchListData = []
const loadBatches = async () => {
if (!$('batch_search')) return
const q = encodeURIComponent($('batch_search').value || '')
const r = await api(`/batches?q=${q}&limit=200`)
const data = (await r.json()).data || []
batchListData = data
const tb = $('batch_listTable').querySelector('tbody')
tb.innerHTML = ''
if (!data.length) { tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted small">暂无批次</td></tr>'; return }
data.forEach(b => {
const tr = document.createElement('tr')
tr.innerHTML = `
<td><strong>${b.title || '(未命名)'}</strong><br><small class="text-muted">${b.id.slice(0, 10)}…</small></td>
<td>${b.summary || '-'}<br>${b.skip_external_sync ? '<small class="text-secondary">⏸不联动下游</small>' : ''}</td>
<td>${b.range_start || ''}<br>~ ${b.range_end || ''}</td>
<td><span class="text-primary">${b.generated}</span> / <span class="text-success">${b.inserted}</span> / <span class="text-secondary">${b.dedup}</span> / <span class="text-danger">${b.rolled_back}</span><br><small class="text-muted">预重${b.pre_existing}</small></td>
<td>${badgeFor(b.status)}</td>
<td><small>${(b.created_at || '').slice(0, 16).replace('T', ' ')}</small></td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info" data-act="view">👁 查看</button>
<button type="button" class="btn btn-outline-primary" data-act="copy">📋 复制</button>
<button type="button" class="btn btn-outline-warning" data-act="rollback">♻ 回滚</button>
<button type="button" class="btn btn-outline-secondary" data-act="export">💾 导出</button>
</div>
</td>`
tr.querySelectorAll('[data-act]').forEach(btnel => btnel.addEventListener('click', (ev) => onBatchAction(ev.currentTarget.dataset.act, b.id)))
tb.appendChild(tr)
})
}
const onBatchAction = async (act, id) => {
if (act === 'view') return showDetail(id)
if (act === 'export') { window.open(`/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(id)}`, '_blank', 'noopener'); return }
if (act === 'copy') {
const found = batchListData.find(b => b.id === id)
if (!found) return
fillForm(Object.assign({}, found.params, { batchName: (found.title || '') + ' (复制)' }))
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-generator'))
tabs.show()
toast('参数已填回生成器,修改后即可预览新批次', 'info')
return
}
if (act === 'rollback') {
const hard = confirm('点"确定"=逻辑删除(推荐,deleted=1);点"取消"=改为物理删除\n(逻辑删除保留行、方便二次确认;物理删不可恢复。)') === false
const action = hard ? '物理删除' : '逻辑删除'
if (!confirm(`确认对批次 ${id.slice(0, 10)}… 执行 ${action} 回滚?`)) return
const r = await api('/batch/rollback', { method: 'POST', body: { id, hard } })
const j = await r.json()
if (!j.ok) return toast('回滚失败: ' + j.error, 'error')
toast(`回滚完成:${j.rolled_back} 条 ${j.hard ? '(硬删)' : '(软删)'}`, 'success')
loadBatches()
}
}
const showDetail = async (id) => {
const r = await api(`/batch/detail?id=${encodeURIComponent(id)}`)
const j = await r.json()
if (!j.ok) return toast('detail failed: ' + j.error, 'error')
const b = j.batch
const counts = b.counts || { active: 0, deleted: 0 }
const rows = (b.sample || []).map(s => `<tr><td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td><td>${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}</td></tr>`).join('')
const diags = j.diagnostics || {}
const diagBtn = (label, keyName) => `<button class="btn btn-sm btn-outline-secondary m-1" type="button" data-diag-key="${keyName}">${label}(复制)</button>`
$('batch_detailBody').innerHTML = `
<h6>${b.title || '(未命名)'} <small class="text-muted ms-2">id: ${b.id}</small></h6>
<p>模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}</p>
<p>生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '<span class="badge bg-secondary">不联动下游</span>':''}</p>
<p>当前表计数:<span class="text-success">active ${counts.active}</span> / <span class="text-danger">deleted ${counts.deleted}</span></p>
<div class="mb-2"><strong>诊断 SQL(一键复制):</strong>
<div class="batch-detail-diag">
${diagBtn('按 batch_id 清单', 'by_batch_id')}
${diagBtn('按 batch_id 计数', 'counts_by_batch')}
${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
</div>
</div>
<div class="mb-2"><strong>样例(前 30 条):</strong></div>
<div class="table-responsive"><table class="table table-sm table-hover"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th><th>状态</th></tr></thead><tbody>${rows || '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'}</tbody></table></div>
`
$('batch_detailBody').querySelectorAll('[data-diag-key]').forEach(bx => bx.addEventListener('click', async () => {
const k = bx.getAttribute('data-diag-key') || ''
const ok = await copy(diags[k] || '')
toast(ok ? '已复制到剪贴板' : '复制失败(可手动选取文本复制)', ok ? 'success' : 'error')
}))
const m = bootstrap.Modal.getOrCreateInstance($('batchDetailModal'))
m.show()
}
const openMainWindow = () => {
try { window.open('index.html', '_blank', 'noopener') } catch {}
}
document.addEventListener('DOMContentLoaded', () => {
// --- 独立页面初始化:如果是 batch_tool.html ---
const mode = $('batch_mode')
if (mode) {
refreshModeVisibility()
mode.addEventListener('change', refreshModeVisibility)
}
const backBtn = $('btnBackToMain')
if (backBtn) backBtn.addEventListener('click', openMainWindow)
const topReload = $('btnReload')
if (topReload) topReload.addEventListener('click', loadBatches)
const pb = $('batch_btnPreview'), ab = $('batch_btnApply'), db = $('batch_btnDownload')
if (pb) pb.addEventListener('click', onPreview)
if (ab) ab.addEventListener('click', onApply)
if (db) db.addEventListener('click', onDownload)
const tabBatches = $('tab-batches')
if (tabBatches) tabBatches.addEventListener('shown.bs.tab', loadBatches)
const rf = $('batch_refresh')
if (rf) rf.addEventListener('click', loadBatches)
const search = $('batch_search')
if (search) {
let _t = null
search.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(loadBatches, 280) })
}
// 独立页面:默认就初始化一次
if (mode) {
latestPreview = null
// 默认时间范围:本月 1 号起 ~ 今年 12 月 31 日止
if (!$('batch_rangeStart').value) {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
$('batch_rangeStart').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
$('batch_rangeEnd').value = `${d.getFullYear()}-12-31`
}
}
// SQL Tab 绑定
const sqlCheck = $('sql_btnCheck')
const sqlApply = $('sql_btnApply')
const sqlDownload = $('sql_btnDownload')
if (sqlCheck) sqlCheck.addEventListener('click', sqlOnCheck)
if (sqlApply) sqlApply.addEventListener('click', sqlOnApply)
if (sqlDownload) sqlDownload.addEventListener('click', sqlOnDownload)
const dupDetailBtn = $('sql_dupDetail')
if (dupDetailBtn) dupDetailBtn.addEventListener('click', sqlOpenDuplicateModal)
const dupConfirmBtn = $('sql_dupConfirm')
if (dupConfirmBtn) dupConfirmBtn.addEventListener('click', async () => {
_sqlConfirmDup = true
await sqlReallyApply({ confirm_duplicates: true, confirm_forbidden: _sqlConfirmForbidden })
})
const dupCancelBtn = $('sql_dupCancel')
if (dupCancelBtn) dupCancelBtn.addEventListener('click', () => { toast('已取消,请回去改 SQL 再试', 'warn') })
})
// 暴露给 batch_tool.html 里 onclick 调用(loadBatches)
window.CalendarBatchTool = { loadBatches, fillForm, readForm }
})()
@@ -0,0 +1,19 @@
(function () {
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('btnOpenBatchTool');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.preventDefault();
try {
window.open(
'batch_tool.html',
'calendar_batch_' + Date.now(),
'width=1280,height=900,resizable=yes,scrollbars=yes'
);
} catch (err) {
// 兜底:普通链接也能打开
document.location.href = 'batch_tool.html';
}
});
});
})();