feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "短链接工具的鉴权配置",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||
"cookieName": "short_link_gate",
|
||||
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Yang's Toolbox · 短链接</title>
|
||||
<link rel="icon" href="/assets/picker.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/short_link/short_link.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="tabs">
|
||||
<a class="tab" href="/">首页</a>
|
||||
<a class="tab active" href="/tools/short_link/index.html">短链接</a>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<span id="mode-badge" class="mode-badge">debug</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<!-- 创建区域 -->
|
||||
<section class="card create-card">
|
||||
<h2>创建短链接</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-row full">
|
||||
<label for="inp-url">目标链接 <span class="req">*</span></label>
|
||||
<input id="inp-url" type="url" placeholder="https://example.com/very/long/path?with=params" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="inp-title">备注标题</label>
|
||||
<input id="inp-title" type="text" placeholder="可选,方便识别" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="inp-code">自定义短码</label>
|
||||
<input id="inp-code" type="text" placeholder="留空则自动生成" maxlength="20" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="inp-expires">过期时间</label>
|
||||
<input id="inp-expires" type="datetime-local" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="inp-maxclicks">最大点击数</label>
|
||||
<input id="inp-maxclicks" type="number" min="0" placeholder="不限" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button id="btn-create" class="btn btn-primary">生成短链接</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 工具别名区域 -->
|
||||
<section class="card alias-card">
|
||||
<h2>工具快捷入口</h2>
|
||||
<p class="hint">以下别名在 config/short_link.json 中配置,访问对应短链接可直接跳转到工具</p>
|
||||
<div id="alias-list" class="alias-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<section class="card list-card">
|
||||
<div class="list-header">
|
||||
<h2>短链接列表</h2>
|
||||
<div class="list-toolbar">
|
||||
<input id="inp-search" type="text" placeholder="搜索短码/链接/标题…" />
|
||||
<button id="btn-search" class="btn btn-sm">搜索</button>
|
||||
<button id="btn-refresh" class="btn btn-sm">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="link-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>短链接</th>
|
||||
<th>目标</th>
|
||||
<th>备注</th>
|
||||
<th>点击</th>
|
||||
<th>创建时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="link-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination" class="pagination"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 交互详情弹窗 -->
|
||||
<div class="modal hidden" id="interaction-modal">
|
||||
<div class="modal-body interaction-modal-body">
|
||||
<h3 id="interaction-title">交互详情</h3>
|
||||
<div id="interaction-content" class="interaction-content"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-interaction-copy" class="btn btn-sm">复制日志</button>
|
||||
<button id="btn-interaction-close" class="btn btn-sm">关闭</button>
|
||||
<span id="interaction-countdown" class="countdown"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认弹窗 -->
|
||||
<div class="modal hidden" id="confirm-modal">
|
||||
<div class="modal-body confirm-modal-body">
|
||||
<h3 id="confirm-title">确认操作</h3>
|
||||
<p id="confirm-message"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-confirm-yes" class="btn btn-danger btn-sm">确认</button>
|
||||
<button id="btn-confirm-no" class="btn btn-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div class="modal hidden" id="edit-modal">
|
||||
<div class="modal-body edit-modal-body">
|
||||
<h3>编辑短链接</h3>
|
||||
<input type="hidden" id="edit-code" />
|
||||
<div class="form-grid">
|
||||
<div class="form-row full">
|
||||
<label for="edit-url">目标链接</label>
|
||||
<input id="edit-url" type="url" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="edit-title">备注标题</label>
|
||||
<input id="edit-title" type="text" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="edit-expires">过期时间</label>
|
||||
<input id="edit-expires" type="datetime-local" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="edit-maxclicks">最大点击数</label>
|
||||
<input id="edit-maxclicks" type="number" min="0" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="edit-active">状态</label>
|
||||
<select id="edit-active">
|
||||
<option value="1">启用</option>
|
||||
<option value="0">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-edit-save" class="btn btn-primary btn-sm">保存</button>
|
||||
<button id="btn-edit-cancel" class="btn btn-sm">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计弹窗 -->
|
||||
<div class="modal hidden" id="stats-modal">
|
||||
<div class="modal-body stats-modal-body">
|
||||
<h3 id="stats-title">点击统计</h3>
|
||||
<div id="stats-summary" class="stats-summary"></div>
|
||||
<div id="stats-clicks" class="stats-clicks"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-stats-close" class="btn btn-sm">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="/common/common.js"></script>
|
||||
<script src="/tools/short_link/short_link.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
/* ===== 短链接工具样式 ===== */
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--success: #16a34a;
|
||||
--bg: #f8fafc;
|
||||
--card-bg: #fff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
body { background: var(--bg); color: var(--text); min-height: 100vh }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 16px 12px 40px }
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600 }
|
||||
.hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 12px }
|
||||
|
||||
/* 模式徽章 */
|
||||
.mode-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-badge.normal { background: #d1fae5; color: #065f46 }
|
||||
|
||||
/* 表单 */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px }
|
||||
.form-row { display: flex; flex-direction: column; gap: 4px }
|
||||
.form-row.full { grid-column: 1 / -1 }
|
||||
.form-row label { font-size: 13px; font-weight: 500; color: var(--text-secondary) }
|
||||
.form-row .req { color: var(--danger) }
|
||||
.form-row input, .form-row select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus { border-color: var(--primary) }
|
||||
.form-actions { margin-top: 14px; display: flex; gap: 8px }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn:hover { background: #f1f5f9 }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary) }
|
||||
.btn-primary:hover { background: var(--primary-hover) }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger) }
|
||||
.btn-danger:hover { background: var(--danger-hover) }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px }
|
||||
.btn-link {
|
||||
border: none; background: none; color: var(--primary);
|
||||
cursor: pointer; font-size: 12px; padding: 2px 4px; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 别名网格 */
|
||||
.alias-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px }
|
||||
.alias-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.alias-item code {
|
||||
background: #e0e7ff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
.alias-item .alias-target { color: var(--text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
|
||||
/* 列表 */
|
||||
.list-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px }
|
||||
.list-header h2 { margin: 0 }
|
||||
.list-toolbar { display: flex; gap: 6px; align-items: center }
|
||||
.list-toolbar input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 200px;
|
||||
outline: none;
|
||||
}
|
||||
.list-toolbar input:focus { border-color: var(--primary) }
|
||||
|
||||
.table-wrap { overflow-x: auto }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px }
|
||||
th { text-align: left; padding: 8px 10px; border-bottom: 2px solid var(--border); color: var(--text-secondary); font-weight: 600; white-space: nowrap }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle }
|
||||
tr:hover td { background: #f8fafc }
|
||||
|
||||
.short-url { font-weight: 600; color: var(--primary); cursor: pointer; white-space: nowrap }
|
||||
.short-url:hover { text-decoration: underline }
|
||||
.target-url { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); font-size: 12px }
|
||||
.status-active { color: var(--success); font-weight: 600 }
|
||||
.status-inactive { color: var(--danger); font-weight: 600 }
|
||||
.status-expired { color: #d97706; font-weight: 600 }
|
||||
.actions { display: flex; gap: 4px; flex-wrap: nowrap }
|
||||
|
||||
/* 分页 */
|
||||
.pagination { display: flex; justify-content: center; gap: 6px; margin-top: 12px }
|
||||
.pagination button {
|
||||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: #fff; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.pagination button.active { background: var(--primary); color: #fff; border-color: var(--primary) }
|
||||
.pagination button:disabled { opacity: .4; cursor: not-allowed }
|
||||
|
||||
/* 弹窗 */
|
||||
.interaction-modal-body { max-height: 80vh; overflow-y: auto; width: 600px }
|
||||
.interaction-content {
|
||||
background: #1e293b; color: #e2e8f0; border-radius: 6px;
|
||||
padding: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 50vh; overflow-y: auto;
|
||||
}
|
||||
.countdown { font-size: 12px; color: var(--text-secondary); margin-left: auto }
|
||||
|
||||
.confirm-modal-body { width: 400px }
|
||||
.edit-modal-body { width: 520px }
|
||||
.stats-modal-body { width: 600px; max-height: 80vh; overflow-y: auto }
|
||||
|
||||
.stats-summary {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px;
|
||||
}
|
||||
.stat-box {
|
||||
background: #f1f5f9; border-radius: 6px; padding: 10px; text-align: center;
|
||||
}
|
||||
.stat-box .stat-num { font-size: 22px; font-weight: 700; color: var(--primary) }
|
||||
.stat-box .stat-label { font-size: 11px; color: var(--text-secondary); margin-top: 2px }
|
||||
|
||||
.stats-clicks { max-height: 40vh; overflow-y: auto }
|
||||
.click-row {
|
||||
display: flex; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
}
|
||||
.click-row .click-time { white-space: nowrap; color: var(--text) }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 10px 20px; border-radius: 8px; font-size: 13px; color: #fff;
|
||||
z-index: 2000; transition: opacity .3s; box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
}
|
||||
.toast.info { background: var(--primary) }
|
||||
.toast.success { background: var(--success) }
|
||||
.toast.error { background: var(--danger) }
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state { text-align: center; padding: 32px; color: var(--text-secondary); font-size: 14px }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.form-grid { grid-template-columns: 1fr }
|
||||
.alias-grid { grid-template-columns: 1fr }
|
||||
.list-header { flex-direction: column; align-items: flex-start }
|
||||
.list-toolbar { width: 100% }
|
||||
.list-toolbar input { flex: 1; width: auto }
|
||||
.stats-summary { grid-template-columns: 1fr }
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/* ===== 短链接工具前端逻辑 ===== */
|
||||
;(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, '>').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 = '<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()
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user