Files
Toolbox/public/tools/short_link/short_link.js
T
yangxiangyuan 154b79c8f1 feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
2026-07-28 16:23:37 +08:00

424 lines
16 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 () {
'use strict'
// ===== 全局状态 =====
let UI_MODE = 'debug'
let DOMAIN = ''
let TOOL_ALIASES = {}
let currentPage = 1
const pageSize = 20
let interactionTimer = null
// ===== DOM 引用 =====
const $ = id => document.getElementById(id)
// ===== 工具函数 =====
const escHtml = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const fullShortUrl = code => {
const base = DOMAIN || (location.origin)
return `${base}/l/${code}`
}
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
return true
}
}
// ===== Toast(normal 模式轻提示)=====
const showToast = (msg, type = 'info') => {
const t = $('toast')
t.textContent = msg
t.className = `toast ${type}`
clearTimeout(t._timer)
t._timer = setTimeout(() => { t.className = 'toast hidden' }, 2000)
}
// ===== 交互详情弹窗(双模式)=====
const showInteraction = (title, detail, mode = 'view') => {
if (UI_MODE === 'normal' && mode === 'view') {
showToast(detail.ok ? '操作成功' : (detail.error || '操作失败'), detail.ok ? 'success' : 'error')
return
}
const lines = []
lines.push(`动作: ${title}`)
lines.push(`时间: ${fmtCNDateTime(new Date())}`)
if (detail.method) lines.push(`方法: ${detail.method}`)
if (detail.url) lines.push(`接口: ${detail.url}`)
if (detail.params) lines.push(`请求参数: ${JSON.stringify(detail.params, null, 2)}`)
if (detail.status !== undefined) lines.push(`HTTP 状态: ${detail.status}`)
if (detail.response) lines.push(`返回结果: ${JSON.stringify(detail.response, null, 2)}`)
if (detail.error) lines.push(`错误信息: ${detail.error}`)
if (detail.duration !== undefined) lines.push(`耗时: ${detail.duration}ms`)
lines.push(`当前状态: ${detail.ok ? '成功' : '失败'}`)
$('interaction-title').textContent = title
$('interaction-content').textContent = lines.join('\n')
$('interaction-modal').classList.remove('hidden')
// 自动关闭倒计时
clearInterval(interactionTimer)
const cd = $('interaction-countdown')
if (detail.ok) {
const secs = UI_MODE === 'debug' ? 3 : 2
let remain = secs
cd.textContent = `${remain}s 后关闭`
interactionTimer = setInterval(() => {
remain--
if (remain <= 0) {
clearInterval(interactionTimer)
closeInteraction()
} else {
cd.textContent = `${remain}s 后关闭`
}
}, 1000)
} else {
cd.textContent = '异常停留,请排查'
}
}
const closeInteraction = () => {
clearInterval(interactionTimer)
$('interaction-modal').classList.add('hidden')
}
// ===== 确认弹窗 =====
let confirmResolve = null
const openConfirm = (title, message) => {
return new Promise(resolve => {
confirmResolve = resolve
$('confirm-title').textContent = title
$('confirm-message').textContent = message
$('confirm-modal').classList.remove('hidden')
})
}
const closeConfirm = (result) => {
$('confirm-modal').classList.add('hidden')
if (confirmResolve) { confirmResolve(result); confirmResolve = null }
}
// ===== 按钮 Loading =====
const setLoading = (btn, loading, text) => {
if (loading) {
btn._origText = btn.textContent
btn.disabled = true
btn.textContent = text || '处理中…'
} else {
btn.disabled = false
btn.textContent = btn._origText || btn.textContent
}
}
// ===== API 请求封装 =====
const api = async (method, path, body) => {
const url = `/api/short_link${path}`
const t0 = performance.now()
try {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body && method !== 'GET') opts.body = JSON.stringify(body)
const resp = await fetch(url, opts)
const data = await resp.json()
const duration = Math.round(performance.now() - t0)
return { ok: data.ok !== false, status: resp.status, data, duration, method, url, params: body || null }
} catch (e) {
const duration = Math.round(performance.now() - t0)
return { ok: false, status: 0, data: null, error: String(e.message || e), duration, method, url, params: body || null }
}
}
// ===== 加载配置 =====
const loadConfig = async () => {
const r = await api('GET', '/config')
if (r.ok && r.data) {
DOMAIN = r.data.domain || ''
TOOL_ALIASES = r.data.tool_aliases || {}
UI_MODE = r.data.ui_mode || 'debug'
const badge = $('mode-badge')
badge.textContent = UI_MODE
badge.className = `mode-badge ${UI_MODE === 'normal' ? 'normal' : ''}`
renderAliases()
}
}
// ===== 渲染工具别名 =====
const renderAliases = () => {
const wrap = $('alias-list')
const entries = Object.entries(TOOL_ALIASES)
if (entries.length === 0) {
wrap.innerHTML = '<div class="empty-state">暂无配置别名</div>'
return
}
wrap.innerHTML = entries.map(([code, target]) => `
<div class="alias-item">
<code>${escHtml(code)}</code>
<span class="alias-target" title="${escHtml(target)}">${escHtml(target)}</span>
<button class="btn-link" data-copy="${escHtml(fullShortUrl(code))}">复制</button>
</div>
`).join('')
}
// ===== 渲染列表 =====
const renderList = (rows, total, page) => {
const tbody = $('link-tbody')
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">暂无短链接</td></tr>'
renderPagination(0, page)
return
}
tbody.innerHTML = rows.map(r => {
const shortUrl = fullShortUrl(r.code)
const now = fmtCNDateTime(new Date())
let statusHtml = '<span class="status-active">启用</span>'
if (!r.is_active) statusHtml = '<span class="status-inactive">停用</span>'
else if (r.expires_at && r.expires_at < now) statusHtml = '<span class="status-expired">已过期</span>'
else if (r.max_clicks && r.click_count >= r.max_clicks) statusHtml = '<span class="status-expired">已达上限</span>'
return `<tr>
<td><span class="short-url" data-copy="${escHtml(shortUrl)}" title="点击复制">${escHtml(shortUrl)}</span></td>
<td><span class="target-url" title="${escHtml(r.target_url)}">${escHtml(r.target_url)}</span></td>
<td>${escHtml(r.title || '-')}</td>
<td>${r.click_count}${r.max_clicks ? ' / ' + r.max_clicks : ''}</td>
<td style="white-space:nowrap;font-size:12px">${escHtml(r.created_at || '')}</td>
<td>${statusHtml}</td>
<td class="actions">
<button class="btn-link" data-action="copy" data-url="${escHtml(shortUrl)}">复制</button>
<button class="btn-link" data-action="edit" data-code="${escHtml(r.code)}">编辑</button>
<button class="btn-link" data-action="stats" data-code="${escHtml(r.code)}">统计</button>
<button class="btn-link" style="color:var(--danger)" data-action="delete" data-code="${escHtml(r.code)}">删除</button>
</td>
</tr>`
}).join('')
renderPagination(total, page)
}
const renderPagination = (total, page) => {
const wrap = $('pagination')
const pages = Math.ceil(total / pageSize)
if (pages <= 1) { wrap.innerHTML = ''; return }
let html = `<button ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">上一页</button>`
for (let i = 1; i <= pages && i <= 10; i++) {
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`
}
html += `<button ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">下一页</button>`
wrap.innerHTML = html
}
// ===== 加载列表 =====
const loadList = async (page = 1, keyword = '') => {
currentPage = page
const qs = `?page=${page}&pageSize=${pageSize}${keyword ? '&keyword=' + encodeURIComponent(keyword) : ''}`
const r = await api('GET', `/list${qs}`)
if (r.ok && r.data) {
renderList(r.data.rows, r.data.total, r.data.page)
} else {
showInteraction('加载列表', r)
}
}
// ===== 创建短链接 =====
const doCreate = async () => {
const btn = $('btn-create')
const targetUrl = $('inp-url').value.trim()
if (!targetUrl) { showToast('请输入目标链接', 'error'); return }
const params = {
target_url: targetUrl,
title: $('inp-title').value.trim(),
code: $('inp-code').value.trim() || undefined,
expires_at: $('inp-expires').value ? $('inp-expires').value.replace('T', ' ') + ':00' : null,
max_clicks: $('inp-maxclicks').value ? Number($('inp-maxclicks').value) : null,
source_type: 'manual'
}
setLoading(btn, true, '生成中…')
const r = await api('POST', '/create', params)
setLoading(btn, false)
if (r.ok && r.data && r.data.data) {
const link = r.data.data
const shortUrl = fullShortUrl(link.code)
showInteraction('创建短链接', { ...r, response: r.data })
// 清空表单
$('inp-url').value = ''
$('inp-title').value = ''
$('inp-code').value = ''
$('inp-expires').value = ''
$('inp-maxclicks').value = ''
// 刷新列表
loadList(1, $('inp-search').value.trim())
// 自动复制
copyText(shortUrl)
if (UI_MODE === 'normal') showToast(`已创建并复制: ${shortUrl}`, 'success')
} else {
showInteraction('创建短链接', r)
}
}
// ===== 编辑 =====
const openEdit = async (code) => {
const r = await api('GET', `/list?keyword=${encodeURIComponent(code)}&pageSize=1`)
if (!r.ok || !r.data || !r.data.rows || !r.data.rows.length) {
showToast('未找到该短链接', 'error')
return
}
const link = r.data.rows[0]
$('edit-code').value = link.code
$('edit-url').value = link.target_url
$('edit-title').value = link.title || ''
$('edit-expires').value = link.expires_at ? link.expires_at.replace(' ', 'T').slice(0, 16) : ''
$('edit-maxclicks').value = link.max_clicks || ''
$('edit-active').value = link.is_active ? '1' : '0'
$('edit-modal').classList.remove('hidden')
}
const doEditSave = async () => {
const btn = $('btn-edit-save')
const code = $('edit-code').value
const params = {
target_url: $('edit-url').value.trim(),
title: $('edit-title').value.trim(),
expires_at: $('edit-expires').value ? $('edit-expires').value.replace('T', ' ') + ':00' : null,
max_clicks: $('edit-maxclicks').value ? Number($('edit-maxclicks').value) : null,
is_active: $('edit-active').value === '1'
}
setLoading(btn, true, '保存中…')
const r = await api('PUT', `/${encodeURIComponent(code)}`, params)
setLoading(btn, false)
$('edit-modal').classList.add('hidden')
showInteraction('编辑短链接', { ...r, response: r.data })
if (r.ok) loadList(currentPage, $('inp-search').value.trim())
}
// ===== 删除 =====
const doDelete = async (code) => {
const yes = await openConfirm('删除短链接', `确定要删除短码 "${code}" 吗?删除后该短链接将立即失效,此操作不可恢复。`)
if (!yes) return
const r = await api('DELETE', `/${encodeURIComponent(code)}`)
showInteraction('删除短链接', { ...r, response: r.data })
if (r.ok) loadList(currentPage, $('inp-search').value.trim())
}
// ===== 统计 =====
const openStats = async (code) => {
const r = await api('GET', `/stats/${encodeURIComponent(code)}?limit=200`)
if (!r.ok || !r.data) {
showToast('获取统计失败', 'error')
return
}
const { link, clicks } = r.data
$('stats-title').textContent = `点击统计 - ${code}`
$('stats-summary').innerHTML = `
<div class="stat-box"><div class="stat-num">${link.click_count}</div><div class="stat-label">总点击</div></div>
<div class="stat-box"><div class="stat-num">${clicks.length}</div><div class="stat-label">最近记录</div></div>
<div class="stat-box"><div class="stat-num">${link.is_active ? '启用' : '停用'}</div><div class="stat-label">当前状态</div></div>
`
if (clicks.length === 0) {
$('stats-clicks').innerHTML = '<div class="empty-state">暂无点击记录</div>'
} else {
$('stats-clicks').innerHTML = clicks.map(c => `
<div class="click-row">
<span class="click-time">${escHtml(c.clicked_at)}</span>
<span>${escHtml(c.ip || '-')}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(c.user_agent || '')}">${escHtml(c.user_agent || '-')}</span>
</div>
`).join('')
}
$('stats-modal').classList.remove('hidden')
}
// ===== 事件绑定 =====
const bindEvents = () => {
// 创建
$('btn-create').addEventListener('click', doCreate)
$('inp-url').addEventListener('keydown', e => { if (e.key === 'Enter') doCreate() })
// 搜索 & 刷新
$('btn-search').addEventListener('click', () => loadList(1, $('inp-search').value.trim()))
$('inp-search').addEventListener('keydown', e => { if (e.key === 'Enter') loadList(1, $('inp-search').value.trim()) })
$('btn-refresh').addEventListener('click', () => loadList(currentPage, $('inp-search').value.trim()))
// 交互弹窗
$('btn-interaction-close').addEventListener('click', closeInteraction)
$('btn-interaction-copy').addEventListener('click', () => {
copyText($('interaction-content').textContent)
showToast('已复制交互日志', 'success')
})
// 确认弹窗
$('btn-confirm-yes').addEventListener('click', () => closeConfirm(true))
$('btn-confirm-no').addEventListener('click', () => closeConfirm(false))
// 编辑弹窗
$('btn-edit-save').addEventListener('click', doEditSave)
$('btn-edit-cancel').addEventListener('click', () => $('edit-modal').classList.add('hidden'))
// 统计弹窗
$('btn-stats-close').addEventListener('click', () => $('stats-modal').classList.add('hidden'))
// 分页
$('pagination').addEventListener('click', e => {
const btn = e.target.closest('button[data-page]')
if (btn && !btn.disabled) loadList(Number(btn.dataset.page), $('inp-search').value.trim())
})
// 列表操作(事件委托)
$('link-tbody').addEventListener('click', e => {
const el = e.target.closest('[data-action]')
if (!el) return
const action = el.dataset.action
if (action === 'copy') { copyText(el.dataset.url); showToast('已复制短链接', 'success') }
else if (action === 'edit') openEdit(el.dataset.code)
else if (action === 'stats') openStats(el.dataset.code)
else if (action === 'delete') doDelete(el.dataset.code)
})
// 短链接点击复制
$('link-tbody').addEventListener('click', e => {
const el = e.target.closest('.short-url[data-copy]')
if (el) { copyText(el.dataset.copy); showToast('已复制短链接', 'success') }
})
// 别名复制
$('alias-list').addEventListener('click', e => {
const el = e.target.closest('[data-copy]')
if (el) { copyText(el.dataset.copy); showToast('已复制短链接', 'success') }
})
// 弹窗背景点击关闭
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', e => {
if (e.target === m) {
if (m.id === 'interaction-modal') closeInteraction()
else if (m.id === 'confirm-modal') closeConfirm(false)
else m.classList.add('hidden')
}
})
})
}
// ===== 初始化 =====
const init = async () => {
bindEvents()
await loadConfig()
await loadList(1)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
})()