Files
Toolbox/public/tools/calendar_reminder/js/batch.js
T
yangxiangyuan db7648eb4d feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
2026-09-02 22:20:37 +08:00

496 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const $ = (id) => document.getElementById(id)
const api = (path, opts = {}) => fetch(`/api/calendar_reminder/${path.replace(/^\//, '')}`, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts, { body: (opts.body && typeof opts.body !== 'string') ? JSON.stringify(opts.body) : opts.body }))
const copy = async (text) => {
try { await navigator.clipboard.writeText(text); return true } catch { return false }
}
const setBtn = (el, loading, text) => { if (!el) return; if (loading) { el.disabled = true; el.dataset.old = el.textContent; el.textContent = text || el.textContent + ' 中…' } else { el.disabled = false; if (el.dataset.old) el.textContent = el.dataset.old } }
const toastNode = (msg, type) => {
if (typeof msg !== 'string' || !msg) return
const div = document.createElement('div')
const colorMap = { success: '#d1e7dd', error: '#f8d7da', info: '#cff4fc', warn: '#fff3cd' }
const fgMap = { success: '#0f5132', error: '#842029', info: '#055160', warn: '#664d03' }
const bg = colorMap[type || 'info'] || colorMap.info
const fg = fgMap[type || 'info'] || fgMap.info
div.textContent = msg
div.style.cssText = `position:fixed;top:64px;right:24px;z-index:99999;padding:8px 14px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,0.18);background:${bg};color:${fg};font-size:13px;max-width:420px;animation:cbFadeIn 0.18s ease-out;`
if (!document.getElementById('cbToastStyles')) {
const s = document.createElement('style'); s.id = 'cbToastStyles'
s.textContent = `@keyframes cbFadeIn { from { transform: translateY(-6px); opacity: 0;} to { transform: translateY(0); opacity:1; } }`
document.head.appendChild(s)
}
document.body.appendChild(div)
setTimeout(() => { div.style.transition = 'opacity 0.2s'; div.style.opacity = '0'; setTimeout(() => div.remove(), 240) }, 2200)
}
const toast = (msg, type) => {
try {
if (window.CAL && window.CAL.toast) window.CAL.toast(msg, type)
else toastNode(msg, type)
} catch { toastNode(msg, type) }
}
const readForm = () => {
const mode = $('batch_mode').value
const weekdays = [...document.querySelectorAll('.batch-wd:checked')].map(c => Number(c.value))
const params = {
batchName: $('batch_name').value.trim(),
mode,
rangeStart: $('batch_rangeStart').value,
rangeEnd: $('batch_rangeEnd').value,
allDay: $('batch_allDay').checked,
completed: $('batch_completed').checked,
startTime: $('batch_startTime').value || '',
endTime: $('batch_endTime').value || '',
remindTime: $('batch_remindTime').value || '',
title: $('batch_title').value.trim(),
content: $('batch_content').value
}
if (mode === 'daily') params.dailyEvery = Number($('batch_dailyEvery').value || 1)
if (mode === 'weekly') { params.weeklyEvery = Number($('batch_weeklyEvery').value || 1); params.weekdays = weekdays }
if (mode === 'monthlyByDay') { params.monthlyEvery = Number($('batch_monthlyEvery').value || 1); params.monthlyDays = $('batch_monthlyDays').value.trim() }
if (mode === 'monthlyByNthWeekday') { params.monthlyEvery2 = Number($('batch_monthlyEvery2').value || 1); params.nthWeek = Number($('batch_nthWeek').value || 3); params.nthWeekday = Number($('batch_nthWeekday').value || 0) }
return params
}
const fillForm = (params) => {
if (!params) return
const p = Object.assign({
mode: 'monthlyByDay', rangeStart: '', rangeEnd: '', allDay: false, completed: false,
startTime: '09:00', endTime: '10:00', remindTime: '08:30', title: '', content: ''
}, params || {})
$('batch_name').value = p.batchName || ''
$('batch_mode').value = p.mode
$('batch_rangeStart').value = p.rangeStart
$('batch_rangeEnd').value = p.rangeEnd
$('batch_allDay').checked = !!p.allDay
$('batch_completed').checked = !!p.completed
$('batch_startTime').value = p.startTime
$('batch_endTime').value = p.endTime
$('batch_remindTime').value = p.remindTime
$('batch_title').value = p.title
$('batch_content').value = p.content
$('batch_dailyEvery').value = p.dailyEvery || 1
$('batch_weeklyEvery').value = p.weeklyEvery || 1
if (Array.isArray(p.weekdays)) document.querySelectorAll('.batch-wd').forEach(cb => { cb.checked = p.weekdays.includes(Number(cb.value)) })
$('batch_monthlyEvery').value = p.monthlyEvery || 2
$('batch_monthlyDays').value = p.monthlyDays || '1-5'
$('batch_monthlyEvery2').value = p.monthlyEvery2 || 1
$('batch_nthWeek').value = p.nthWeek || 3
$('batch_nthWeekday').value = String(p.nthWeekday ?? 0)
refreshModeVisibility()
$('batch_btnApply').disabled = true
$('batch_btnDownload').disabled = true
$('batch_previewArea').classList.add('d-none')
}
const refreshModeVisibility = () => {
const mode = $('batch_mode').value
document.querySelectorAll('.mode-fieldset').forEach(el => {
el.style.display = (el.dataset.mode === mode) ? '' : 'none'
})
}
let latestPreview = null
const onPreview = async () => {
const params = readForm()
if (!params.rangeStart || !params.rangeEnd) return toast('请选择时间范围', 'error')
if (!params.title) return toast('请填写标题模板', 'error')
if (params.mode === 'weekly' && (!params.weekdays || !params.weekdays.length)) return toast('每周模式请至少选一个星期几', 'error')
if (params.mode === 'monthlyByDay' && !params.monthlyDays) return toast('请填写每月日期表达式(如 1-5)', 'error')
const btn = $('batch_btnPreview')
setBtn(btn, true, '预览中…')
try {
const r = await api('/batch/preview', { method: 'POST', body: params })
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'preview failed')
latestPreview = data
$('batch_previewArea').classList.remove('d-none')
const stats = `预生成:<b>${data.generated}</b> 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = <code>${data.batchId}</code>`
$('batch_previewStats').innerHTML = stats
if (data.pre_existing_count) {
$('batch_preExisting').textContent = `⚠ 其中 ${data.pre_existing_count} 条 (title, start) 已存在,入库时将命中去重并复用原 id(不会真插入新行)`
} else { $('batch_preExisting').textContent = '' }
const tb = $('batch_sampleTable').querySelector('tbody')
tb.innerHTML = ''
;(data.sample_list || []).forEach(s => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td>`
tb.appendChild(tr)
})
$('batch_sqlText').value = data.sql_display + (data.truncated ? '\n... 超过 500 行,完整 SQL 请点下载 SQL 文件按钮' : '')
$('batch_btnApply').disabled = false
$('batch_btnDownload').disabled = false
} catch (e) {
toast('预览失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onApply = async () => {
if (!latestPreview) return toast('请先点「预览 SQL」', 'error')
const ok = confirm(`确认将 ${latestPreview.generated} 条事件直接写入数据库?\n(可在批次管理里一键回滚逻辑删除)`)
if (!ok) return
const btn = $('batch_btnApply')
setBtn(btn, true, '入库中…')
try {
const r = await api('/batch/apply', {
method: 'POST',
body: { batch_id: latestPreview.batchId, params: latestPreview.params_saved, sql_full: latestPreview.sql_full, skip_external_sync: $('batch_skipExternal').checked }
})
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'apply failed')
toast(`写入成功:生成${data.generated} 入库${data.inserted} 去重${data.dedup} 预重${data.pre_existing}`, 'success')
loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('入库失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onDownload = () => {
if (!latestPreview) return
const blob = new Blob([latestPreview.sql_full], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_batch_preview_${latestPreview.batchId}.sql`
a.click()
URL.revokeObjectURL(a.href)
}
/* ---------- SQL Manual Tab ---------- */
let _sqlLatestReport = null
let _sqlConfirmDup = false
let _sqlConfirmForbidden = false
const pad2 = (n) => String(n).padStart(2, '0')
const tsFile = () => {
const d = new Date()
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
}
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[ch]))
const sqlReadForm = () => ({
batch_name: ($('sql_batch_name') || {}).value || '',
skip_external_sync: !!($('sql_skipExternal') || {}).checked,
mark_completed: !!($('sql_completed') || {}).checked,
sql_text: ($('sql_textarea') || {}).value || ''
})
const sqlRenderReport = (data) => {
_sqlLatestReport = data || null
const area = $('sql_checkArea')
if (!area) return
if (!_sqlLatestReport || !data) { area.classList.add('d-none'); return }
area.classList.remove('d-none')
const total = data.total_statements || 0
const ins = data.insert_statements || 0
const rows = data.total_rows || 0
const ok = !!data.ok
$('sql_checkSummary').textContent = `${ok ? '✅ 通过' : '❌ 未通过'} · ${total} 语句 · ${ins} INSERT · ${rows} 行`
$('sql_checkSummary').className = `badge ${ok ? 'bg-success' : 'bg-danger'}`
// Meta
$('sql_checkMeta').innerHTML = `
阈值:重复相似度 ≥ <b>${data.duplicate_threshold || 0.65}</b>,最多 <b>${data.max_rows_limit || 500}</b> 条,执行超时 <b>${Math.round((data.max_ms_limit || 60000)/1000)}s</b>。<br>
检测到 <b>${data.pre_existing_count || 0}</b> 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)<br>
检测到 <b>${data.high_confidence_duplicates || 0}</b> 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:<b>${data.range_start || '未知起'}</b> ~ <b>${data.range_end || '未知止'}</b>
`
// Forbidden / syntax / col errors
const renderList = (containerId, headerText, list, color) => {
const el = $(containerId)
if (!el) return
if (!list || !list.length) { el.classList.add('d-none'); return }
el.classList.remove('d-none')
const items = list.map((e, idx) => `<li><b>#${idx+1}</b> [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `<br><small class="text-muted">${escapeHtml(e.preview)}</small>` : ''}${e.title ? `<br><small>title=${escapeHtml(e.title)}</small>` : ''}</li>`).join('')
el.innerHTML = `<div class="fw-bold mb-1" style="color:${color}">${headerText}</div><ul style="padding-left:1.25rem">${items}</ul>`
}
renderList('sql_checkForbidden', '❌ 语句类型被拒绝(只允许 INSERT INTO events):', data.forbidden, '#dc3545')
renderList('sql_checkSyntaxErrors', '❌ 语法错误(SQLite EXPLAIN 未通过):', data.syntax_errors, '#dc3545')
renderList('sql_checkColErrors', '❌ 列 / 必填 / 时间格式错误:', data.col_errors, '#dc3545')
// Duplicates
const dupEl = $('sql_checkDuplicate')
if (dupEl) {
if ((data.high_confidence_duplicates || 0) === 0) dupEl.classList.add('d-none')
else {
dupEl.classList.remove('d-none')
dupEl.querySelector('span.fw-bold').textContent = `⚠️ 检测到 ${data.high_confidence_duplicates} 条宽松匹配高置信重复(≥${data.duplicate_threshold||0.65}):`
}
}
// Sample rows
const tb = $('sql_checkSampleTbody')
if (tb) {
tb.innerHTML = ''
const meta = (data.row_meta || []).slice(0, 15)
if (!meta.length) tb.innerHTML = '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'
else meta.forEach(r => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${r.stmt_idx ?? ''}</td><td>${r.row_idx ?? ''}</td><td>${escapeHtml(r.title || '')}</td><td>${escapeHtml(r.start || '')}</td><td>${escapeHtml(r.end || '')}</td>`
tb.appendChild(tr)
})
}
// 执行按钮:允许状态
const applyBtn = $('sql_btnApply')
if (applyBtn) applyBtn.disabled = false
}
const sqlOpenDuplicateModal = () => {
if (!_sqlLatestReport || !_sqlLatestReport.high_confidence_duplicates) return
const rows = (_sqlLatestReport.duplicates || []).filter(d => (d.score || 0) >= (_sqlLatestReport.duplicate_threshold || 0.65) && d.match && d.match.length)
if (!rows.length) return toast('没有高置信重复明细', 'warn')
$('sql_dupSummary').innerHTML = `共 <b>${rows.length}</b> 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
const tb = $('sql_dupTbody')
tb.innerHTML = ''
rows.forEach(d => {
const mainMatch = (d.match || [])[0] || {}
const tr = document.createElement('tr')
tr.innerHTML = `
<td>${d.stmt_idx ?? ''}-${d.row_idx ?? ''}</td>
<td>${escapeHtml(d.title_left || '')}</td>
<td>${escapeHtml(d.start_left || '')}</td>
<td>${escapeHtml(mainMatch.title || '')}</td>
<td>${escapeHtml(mainMatch.start || '')}</td>
<td>${Number(d.score || 0).toFixed(2)}</td>
<td><a href="index.html" target="_blank" class="btn btn-sm btn-outline-primary">打开主日历核对</a></td>`
tb.appendChild(tr)
})
const m = bootstrap.Modal.getOrCreateInstance($('sqlDuplicateModal'))
m.show()
}
const sqlOnCheck = async () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const btn = $('sql_btnCheck')
setBtn(btn, true, '检查中…')
try {
const r = await api('/batch_sql_check', { method: 'POST', body: f })
const data = await r.json()
sqlRenderReport(data)
_sqlConfirmDup = (data.high_confidence_duplicates || 0) === 0
_sqlConfirmForbidden = !!(data.ok)
if (data.ok) toast(`检查通过:${data.insert_statements||0} INSERT / ${data.total_rows||0} 行 / ${data.high_confidence_duplicates||0} 宽松重复`, 'success')
else toast(`检查未通过:${(data.forbidden||[]).length + (data.syntax_errors||[]).length + (data.col_errors||[]).length} 处错误`, 'error')
} catch (e) {
toast('检查失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnApply = async () => {
if (!_sqlLatestReport) return toast('请先点「SQL 语句检查」', 'error')
const f = sqlReadForm()
if (!f.batch_name.trim()) return toast('请填写批次名称(便于回滚时查找)', 'error')
// 先把最新状态再拉一遍(用户可能中间改过 textarea,不允许跳检查直接执行)
const checkBtn = $('sql_btnCheck')
if (checkBtn && checkBtn.disabled !== true) {
// 如果检查按钮不是 loading,强制再跑一次检查,避免用户中间改了 SQL 直接点执行
await sqlOnCheck()
if (!_sqlLatestReport) return
}
const hasErr = ((_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length) > 0
if (hasErr && !_sqlConfirmForbidden) {
const okForb = confirm(`有 ${(_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length} 处语法/列/语句类型被拒绝错误。真的要强制继续执行?(大概率会批量 INSERT 失败;建议先改 SQL 到检查通过)`)
if (!okForb) return
_sqlConfirmForbidden = true
}
const dupCount = _sqlLatestReport.high_confidence_duplicates || 0
if (dupCount > 0 && !_sqlConfirmDup) {
// 先弹重复详情 Modal,用户点 Modal 里的「我已知悉」再真正执行
sqlOpenDuplicateModal()
return
}
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: _sqlConfirmForbidden })
}
const sqlReallyApply = async (extra) => {
const f = Object.assign(sqlReadForm(), extra || {})
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
if (!f.batch_name.trim()) return toast('请填写批次名称', 'error')
const btn = $('sql_btnApply')
setBtn(btn, true, '执行入库中…')
try {
const r = await api('/batch_sql_apply', { method: 'POST', body: f })
const data = await r.json()
if (!r.ok || !data.ok) {
if (r.status === 409 && data.report) {
// 后端强制拦:需要 confirm_forbidden / confirm_duplicates
sqlRenderReport(data.report)
if (data.error && String(data.error).includes('duplicates_need_confirm')) { sqlOpenDuplicateModal(); return }
if (data.error && String(data.error).includes('forbidden_or_error_need_confirm')) {
const ok = confirm('后端强制拦截:有错误语句 / 不允许的列。点确定 = 我已知悉,仍尝试执行(可能全部或部分失败)')
if (!ok) return
_sqlConfirmForbidden = true
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: true })
return
}
}
throw new Error(data.error || `HTTP ${r.status}`)
}
toast(`SQL 批次执行成功:生成${data.generated} / 入库${data.inserted} / 去重${data.dedup} / 宽松重复${data.high_confidence_duplicates||0}。batch_id=${data.batch_id}`, 'success')
_sqlConfirmDup = false
_sqlConfirmForbidden = false
await loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('SQL 批次执行失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnDownload = () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const head = `-- 下载时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n-- 批次名:${f.batch_name || '(未命名)'}\n-- 标记:${f.skip_external_sync ? '不联动下游 ' : ''}${f.mark_completed ? '全部已完成' : ''}\n-- 注意:仅支持 INSERT INTO events(...)\n\n`
const blob = new Blob(['\ufeff' + head + f.sql_text], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_events_sql_${tsFile()}.sql`
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 1500)
toast('SQL 已下载(UTF-8 BOM,避免中文乱码)', 'info')
}
const badgeFor = (st) => {
const map = { draft: 'bg-secondary', inserting: 'bg-primary', inserted: 'bg-success', rolled_back: 'bg-dark', partial: 'bg-warning' }
return `<span class="badge ${map[st] || 'bg-light text-dark'}">${st}</span>`
}
let batchListData = []
const loadBatches = async () => {
if (!$('batch_search')) return
const q = encodeURIComponent($('batch_search').value || '')
const r = await api(`/batches?q=${q}&limit=200`)
const data = (await r.json()).data || []
batchListData = data
const tb = $('batch_listTable').querySelector('tbody')
tb.innerHTML = ''
if (!data.length) { tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted small">暂无批次</td></tr>'; return }
data.forEach(b => {
const tr = document.createElement('tr')
tr.innerHTML = `
<td><strong>${b.title || '(未命名)'}</strong><br><small class="text-muted">${b.id.slice(0, 10)}…</small></td>
<td>${b.summary || '-'}<br>${b.skip_external_sync ? '<small class="text-secondary">⏸不联动下游</small>' : ''}</td>
<td>${b.range_start || ''}<br>~ ${b.range_end || ''}</td>
<td><span class="text-primary">${b.generated}</span> / <span class="text-success">${b.inserted}</span> / <span class="text-secondary">${b.dedup}</span> / <span class="text-danger">${b.rolled_back}</span><br><small class="text-muted">预重${b.pre_existing}</small></td>
<td>${badgeFor(b.status)}</td>
<td><small>${(b.created_at || '').slice(0, 16).replace('T', ' ')}</small></td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info" data-act="view">👁 查看</button>
<button type="button" class="btn btn-outline-primary" data-act="copy">📋 复制</button>
<button type="button" class="btn btn-outline-warning" data-act="rollback">♻ 回滚</button>
<button type="button" class="btn btn-outline-secondary" data-act="export">💾 导出</button>
</div>
</td>`
tr.querySelectorAll('[data-act]').forEach(btnel => btnel.addEventListener('click', (ev) => onBatchAction(ev.currentTarget.dataset.act, b.id)))
tb.appendChild(tr)
})
}
const onBatchAction = async (act, id) => {
if (act === 'view') return showDetail(id)
if (act === 'export') { window.open(`/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(id)}`, '_blank', 'noopener'); return }
if (act === 'copy') {
const found = batchListData.find(b => b.id === id)
if (!found) return
fillForm(Object.assign({}, found.params, { batchName: (found.title || '') + ' (复制)' }))
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-generator'))
tabs.show()
toast('参数已填回生成器,修改后即可预览新批次', 'info')
return
}
if (act === 'rollback') {
const hard = confirm('点"确定"=逻辑删除(推荐,deleted=1);点"取消"=改为物理删除\n(逻辑删除保留行、方便二次确认;物理删不可恢复。)') === false
const action = hard ? '物理删除' : '逻辑删除'
if (!confirm(`确认对批次 ${id.slice(0, 10)}… 执行 ${action} 回滚?`)) return
const r = await api('/batch/rollback', { method: 'POST', body: { id, hard } })
const j = await r.json()
if (!j.ok) return toast('回滚失败: ' + j.error, 'error')
toast(`回滚完成:${j.rolled_back} 条 ${j.hard ? '(硬删)' : '(软删)'}`, 'success')
loadBatches()
}
}
const showDetail = async (id) => {
const r = await api(`/batch/detail?id=${encodeURIComponent(id)}`)
const j = await r.json()
if (!j.ok) return toast('detail failed: ' + j.error, 'error')
const b = j.batch
const counts = b.counts || { active: 0, deleted: 0 }
const rows = (b.sample || []).map(s => `<tr><td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td><td>${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}</td></tr>`).join('')
const diags = j.diagnostics || {}
const diagBtn = (label, keyName) => `<button class="btn btn-sm btn-outline-secondary m-1" type="button" data-diag-key="${keyName}">${label}(复制)</button>`
$('batch_detailBody').innerHTML = `
<h6>${b.title || '(未命名)'} <small class="text-muted ms-2">id: ${b.id}</small></h6>
<p>模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}</p>
<p>生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '<span class="badge bg-secondary">不联动下游</span>':''}</p>
<p>当前表计数:<span class="text-success">active ${counts.active}</span> / <span class="text-danger">deleted ${counts.deleted}</span></p>
<div class="mb-2"><strong>诊断 SQL(一键复制):</strong>
<div class="batch-detail-diag">
${diagBtn('按 batch_id 清单', 'by_batch_id')}
${diagBtn('按 batch_id 计数', 'counts_by_batch')}
${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
</div>
</div>
<div class="mb-2"><strong>样例(前 30 条):</strong></div>
<div class="table-responsive"><table class="table table-sm table-hover"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th><th>状态</th></tr></thead><tbody>${rows || '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'}</tbody></table></div>
`
$('batch_detailBody').querySelectorAll('[data-diag-key]').forEach(bx => bx.addEventListener('click', async () => {
const k = bx.getAttribute('data-diag-key') || ''
const ok = await copy(diags[k] || '')
toast(ok ? '已复制到剪贴板' : '复制失败(可手动选取文本复制)', ok ? 'success' : 'error')
}))
const m = bootstrap.Modal.getOrCreateInstance($('batchDetailModal'))
m.show()
}
const openMainWindow = () => {
try { window.open('index.html', '_blank', 'noopener') } catch {}
}
document.addEventListener('DOMContentLoaded', () => {
// --- 独立页面初始化:如果是 batch_tool.html ---
const mode = $('batch_mode')
if (mode) {
refreshModeVisibility()
mode.addEventListener('change', refreshModeVisibility)
}
const backBtn = $('btnBackToMain')
if (backBtn) backBtn.addEventListener('click', openMainWindow)
const topReload = $('btnReload')
if (topReload) topReload.addEventListener('click', loadBatches)
const pb = $('batch_btnPreview'), ab = $('batch_btnApply'), db = $('batch_btnDownload')
if (pb) pb.addEventListener('click', onPreview)
if (ab) ab.addEventListener('click', onApply)
if (db) db.addEventListener('click', onDownload)
const tabBatches = $('tab-batches')
if (tabBatches) tabBatches.addEventListener('shown.bs.tab', loadBatches)
const rf = $('batch_refresh')
if (rf) rf.addEventListener('click', loadBatches)
const search = $('batch_search')
if (search) {
let _t = null
search.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(loadBatches, 280) })
}
// 独立页面:默认就初始化一次
if (mode) {
latestPreview = null
// 默认时间范围:本月 1 号起 ~ 今年 12 月 31 日止
if (!$('batch_rangeStart').value) {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
$('batch_rangeStart').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
$('batch_rangeEnd').value = `${d.getFullYear()}-12-31`
}
}
// SQL Tab 绑定
const sqlCheck = $('sql_btnCheck')
const sqlApply = $('sql_btnApply')
const sqlDownload = $('sql_btnDownload')
if (sqlCheck) sqlCheck.addEventListener('click', sqlOnCheck)
if (sqlApply) sqlApply.addEventListener('click', sqlOnApply)
if (sqlDownload) sqlDownload.addEventListener('click', sqlOnDownload)
const dupDetailBtn = $('sql_dupDetail')
if (dupDetailBtn) dupDetailBtn.addEventListener('click', sqlOpenDuplicateModal)
const dupConfirmBtn = $('sql_dupConfirm')
if (dupConfirmBtn) dupConfirmBtn.addEventListener('click', async () => {
_sqlConfirmDup = true
await sqlReallyApply({ confirm_duplicates: true, confirm_forbidden: _sqlConfirmForbidden })
})
const dupCancelBtn = $('sql_dupCancel')
if (dupCancelBtn) dupCancelBtn.addEventListener('click', () => { toast('已取消,请回去改 SQL 再试', 'warn') })
})
// 暴露给 batch_tool.html 里 onclick 调用(loadBatches)
window.CalendarBatchTool = { loadBatches, fillForm, readForm }
})()