/* ===== 短链接工具前端逻辑 ===== */ ;(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.click_count}
总点击
${clicks.length}
最近记录
${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() } })()