feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
@@ -0,0 +1,212 @@
(function () {
const DEFAULT_UI_MODE = 'debug'
const AUTO_CLOSE_SEC_DEBUG = 3
const AUTO_CLOSE_SEC_NORMAL = 2
function readUiMode() {
try {
const v = localStorage.getItem('mp_ta_ui_mode')
if (v === 'debug' || v === 'normal') return v
} catch {}
return DEFAULT_UI_MODE
}
function setUiMode(mode) {
try { localStorage.setItem('mp_ta_ui_mode', mode) } catch {}
}
function escapeHtml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function showToast(text, type, duration) {
type = type || 'info'
duration = duration || (type === 'error' ? 3000 : 2000)
const colors = {
info: { bg: 'rgba(56, 103, 214, 0.92)', border: 'rgba(108, 122, 224, 0.6)', color: '#fff' },
success: { bg: 'rgba(0, 180, 140, 0.92)', border: 'rgba(0, 216, 167, 0.6)', color: '#fff' },
error: { bg: 'rgba(220, 53, 69, 0.92)', border: 'rgba(255, 80, 100, 0.6)', color: '#fff' },
warn: { bg: 'rgba(230, 152, 20, 0.92)', border: 'rgba(255, 193, 7, 0.6)', color: '#fff' }
}
const c = colors[type] || colors.info
const t = document.createElement('div')
t.textContent = text
Object.assign(t.style, {
position: 'fixed', zIndex: 99998, top: '28px', left: '50%', transform: 'translateX(-50%)',
padding: '10px 18px', borderRadius: '12px', fontSize: '14px', fontWeight: 500,
maxWidth: '90vw', textAlign: 'center', backdropFilter: 'blur(8px)',
background: c.bg, border: '1px solid ' + c.border, color: c.color,
boxShadow: '0 10px 30px rgba(0,0,0,0.35)'
})
document.body.appendChild(t)
setTimeout(() => {
t.style.transition = 'opacity 0.3s ease, transform 0.3s ease'
t.style.opacity = '0'
t.style.transform = 'translateX(-50%) translateY(-10px)'
setTimeout(() => t.remove(), 320)
}, duration)
}
function confirmDialog(options, onOk, onCancel) {
options = options || {}
const title = options.title || '请确认操作'
const desc = options.desc || ''
const okText = options.okText || '确认'
const cancelText = options.cancelText || '取消'
const danger = options.danger === true
const slot = document.getElementById('modal-slot') || document.body
const mask = document.createElement('div')
mask.className = 'modal-mask'
mask.innerHTML =
'<div class="modal-card">' +
'<div class="modal-title' + (danger ? ' danger' : '') + '">' + escapeHtml(title) + '</div>' +
'<div class="modal-desc">' + escapeHtml(desc) + '</div>' +
'<div class="modal-actions">' +
'<button type="button" class="btn secondary btn-sm" data-act="cancel">' + escapeHtml(cancelText) + '</button>' +
'<button type="button" class="btn ' + (danger ? 'danger' : 'primary') + ' btn-sm" data-act="ok">' + escapeHtml(okText) + '</button>' +
'</div>' +
'</div>'
slot.appendChild(mask)
const close = (result) => {
mask.remove()
if (result && typeof onOk === 'function') onOk()
if (!result && typeof onCancel === 'function') onCancel()
}
mask.addEventListener('click', (e) => {
if (e.target === mask) close(false)
const act = e.target.getAttribute && e.target.getAttribute('data-act')
if (act === 'ok') close(true)
if (act === 'cancel') close(false)
})
const okBtn = mask.querySelector('[data-act="ok"]')
if (okBtn) okBtn.focus()
return { close }
}
function runTrackedInteraction(options, runner) {
const uiMode = readUiMode()
options = options || {}
const name = options.name || '操作'
const toastOnSuccess = options.toastOnSuccess !== false
const normalViewAsToast = options.normalViewAsToast !== false
const debugMode = uiMode === 'debug'
const dlg = document.getElementById('interaction-dialog') || (() => {
const d = document.createElement('div')
d.id = 'interaction-dialog'
d.className = 'interaction-dialog hidden'
document.body.appendChild(d)
return d
})()
const startedAt = Date.now()
const logs = []
let timer = null
let forceStay = false
const fmtTime = (ts) => {
const d = new Date(ts)
const p = (n) => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
const push = (line) => logs.push(`[${fmtTime(Date.now())}] ${line}`)
const render = () => {
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1)
dlg.innerHTML =
'<div class="id-title">' +
'<span>' + escapeHtml(name) + ' · 交互详情</span>' +
'<span style="cursor:pointer;color:#8a8aa8;" data-act="copy">复制</span>' +
'</div>' +
'<div class="id-body">' + escapeHtml(logs.join('\n') + (timer ? `\n\n倒计时关闭:${remainSec}s` : '') + `\n\n耗时:${elapsedSec}s`) + '</div>' +
'<div class="id-actions">' +
'<span class="id-countdown">' + (timer ? `自动关闭中:${remainSec}s` : (forceStay ? '已停留,有错误/异常' : '已完成')) + '</span>' +
'<div style="display:flex;gap:8px;">' +
(timer ? '<button type="button" class="btn ghost btn-sm" data-act="stay">停留</button>' : '') +
'<button type="button" class="btn secondary btn-sm" data-act="close">关闭</button>' +
'</div>' +
'</div>'
const btnCopy = dlg.querySelector('[data-act="copy"]')
if (btnCopy) btnCopy.onclick = () => {
try {
const txt = logs.join('\n') + `\n\n耗时:${elapsedSec}s`
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => showToast('日志已复制', 'success', 1200))
} else {
const ta = document.createElement('textarea')
ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove()
showToast('日志已复制', 'success', 1200)
}
} catch (e) { showToast('复制失败:' + e.message, 'error', 2000) }
}
const btnStay = dlg.querySelector('[data-act="stay"]')
if (btnStay) btnStay.onclick = () => { if (timer) { clearInterval(timer); timer = null; forceStay = true; render() } }
const btnClose = dlg.querySelector('[data-act="close"]')
if (btnClose) btnClose.onclick = () => { dlg.classList.add('hidden'); if (timer) clearInterval(timer); timer = null }
}
const showDlg = () => { dlg.classList.remove('hidden'); render() }
const closeDlg = () => { dlg.classList.add('hidden'); if (timer) { clearInterval(timer); timer = null } }
let remainSec = debugMode ? AUTO_CLOSE_SEC_DEBUG : AUTO_CLOSE_SEC_NORMAL
const startCountdown = () => {
if (forceStay) return
timer = setInterval(() => {
remainSec -= 1
if (remainSec <= 0) { closeDlg() }
else { render() }
}, 1000)
}
push(`开始:${name}`)
push(`HTTP: ${options.method || 'GET'} ${options.path || options.url || '(本地)'}${options.params ? `\n参数:${JSON.stringify(options.params)}` : ''}`)
showDlg()
const runnerPromise = typeof runner === 'function' ? runner() : Promise.resolve(runner || { ok: true })
return Promise.resolve(runnerPromise).then((res) => {
push(`返回:${JSON.stringify(res, null, 2).slice(0, 2000)}`)
push(`状态:成功 ✔ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
render()
if (debugMode) { startCountdown() }
else if (normalViewAsToast && toastOnSuccess) { setTimeout(() => closeDlg(), 120); showToast(`${name}成功`, 'success') }
else { startCountdown() }
return res
}).catch((e) => {
forceStay = true
push(`错误:${e && e.message ? e.message : String(e)}`)
push(`状态:失败 ✘ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
render()
showToast(`${name}失败:${(e && e.message) ? e.message : '未知错误'}`, 'error', 3500)
throw e
})
}
function formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return String(iso)
const p = (n) => (n < 10 ? '0' + n : '' + n)
const now = new Date()
const sameYear = d.getFullYear() === now.getFullYear()
const y = sameYear ? '' : d.getFullYear() + '-'
return `${y}${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function humanSize(bytes) {
const n = Number(bytes || 0)
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(2) + ' MB'
}
window.__MPTA_DIALOG__ = {
readUiMode, setUiMode,
escapeHtml, showToast, confirmDialog, runTrackedInteraction,
formatTime, humanSize,
AUTO_CLOSE_SEC_DEBUG, AUTO_CLOSE_SEC_NORMAL
}
})()