(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 = `预生成:${data.generated} 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = ${data.batchId}` $('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 = `${s.start || ''}${s.end || ''}${s.remindAt || ''}${s.title || ''}` 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 = ` 阈值:重复相似度 ≥ ${data.duplicate_threshold || 0.65},最多 ${data.max_rows_limit || 500} 条,执行超时 ${Math.round((data.max_ms_limit || 60000)/1000)}s。
检测到 ${data.pre_existing_count || 0} 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)
检测到 ${data.high_confidence_duplicates || 0} 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:${data.range_start || '未知起'} ~ ${data.range_end || '未知止'} ` // 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) => `
  • #${idx+1} [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `
    ${escapeHtml(e.preview)}` : ''}${e.title ? `
    title=${escapeHtml(e.title)}` : ''}
  • `).join('') el.innerHTML = `
    ${headerText}
    ` } 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 = '空' else meta.forEach(r => { const tr = document.createElement('tr') tr.innerHTML = `${r.stmt_idx ?? ''}${r.row_idx ?? ''}${escapeHtml(r.title || '')}${escapeHtml(r.start || '')}${escapeHtml(r.end || '')}` 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 = `共 ${rows.length} 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。` const tb = $('sql_dupTbody') tb.innerHTML = '' rows.forEach(d => { const mainMatch = (d.match || [])[0] || {} const tr = document.createElement('tr') tr.innerHTML = ` ${d.stmt_idx ?? ''}-${d.row_idx ?? ''} ${escapeHtml(d.title_left || '')} ${escapeHtml(d.start_left || '')} ${escapeHtml(mainMatch.title || '')} ${escapeHtml(mainMatch.start || '')} ${Number(d.score || 0).toFixed(2)} 打开主日历核对` 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 `${st}` } 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 = '暂无批次'; return } data.forEach(b => { const tr = document.createElement('tr') tr.innerHTML = ` ${b.title || '(未命名)'}
    ${b.id.slice(0, 10)}… ${b.summary || '-'}
    ${b.skip_external_sync ? '⏸不联动下游' : ''} ${b.range_start || ''}
    ~ ${b.range_end || ''} ${b.generated} / ${b.inserted} / ${b.dedup} / ${b.rolled_back}
    预重${b.pre_existing} ${badgeFor(b.status)} ${(b.created_at || '').slice(0, 16).replace('T', ' ')}
    ` 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 => `${s.start || ''}${s.end || ''}${s.remindAt || ''}${s.title || ''}${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}`).join('') const diags = j.diagnostics || {} const diagBtn = (label, keyName) => `` $('batch_detailBody').innerHTML = `
    ${b.title || '(未命名)'} id: ${b.id}

    模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}

    生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '不联动下游':''}

    当前表计数:active ${counts.active} / deleted ${counts.deleted}

    诊断 SQL(一键复制):
    ${diagBtn('按 batch_id 清单', 'by_batch_id')} ${diagBtn('按 batch_id 计数', 'counts_by_batch')} ${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
    样例(前 30 条):
    ${rows || ''}
    开始结束提醒标题状态
    空
    ` $('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 } })()