feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
This commit is contained in:
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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';
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user