/* ===== 短链接工具前端逻辑 ===== */
;(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, '&').replace(//g, '>').replace(/"/g, '"')
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 = '
暂无配置别名
'
return
}
wrap.innerHTML = entries.map(([code, target]) => `
${escHtml(code)}
${escHtml(target)}
`).join('')
}
// ===== 渲染列表 =====
const renderList = (rows, total, page) => {
const tbody = $('link-tbody')
if (!rows || rows.length === 0) {
tbody.innerHTML = '| 暂无短链接 |
'
renderPagination(0, page)
return
}
tbody.innerHTML = rows.map(r => {
const shortUrl = fullShortUrl(r.code)
const now = fmtCNDateTime(new Date())
let statusHtml = '启用'
if (!r.is_active) statusHtml = '停用'
else if (r.expires_at && r.expires_at < now) statusHtml = '已过期'
else if (r.max_clicks && r.click_count >= r.max_clicks) statusHtml = '已达上限'
return `
| ${escHtml(shortUrl)} |
${escHtml(r.target_url)} |
${escHtml(r.title || '-')} |
${r.click_count}${r.max_clicks ? ' / ' + r.max_clicks : ''} |
${escHtml(r.created_at || '')} |
${statusHtml} |
|
`
}).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 = ``
for (let i = 1; i <= pages && i <= 10; i++) {
html += ``
}
html += ``
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 = `
${link.is_active ? '启用' : '停用'}
当前状态
`
if (clicks.length === 0) {
$('stats-clicks').innerHTML = '暂无点击记录
'
} else {
$('stats-clicks').innerHTML = clicks.map(c => `
${escHtml(c.clicked_at)}
${escHtml(c.ip || '-')}
${escHtml(c.user_agent || '-')}
`).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()
}
})()