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