(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 条):
`
$('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 }
})()