feat: redesign admin interface
This commit is contained in:
@@ -1,289 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>服务管理 - ToNav</title>
|
||||
<style>
|
||||
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: #f0f2f5; padding: 20px; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 25px; border-radius: 15px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn-primary { background: var(--main-red); color: #fff; }
|
||||
.btn-back { background: #eee; color: #333; }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
.table-card { background: #fff; border-radius: 15px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #fafafa; font-size: 13px; color: #999; text-align: left; padding: 12px 15px; font-weight: 500; }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid #f5f5f5; font-size: 14px; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
.status-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.status-online { background: #f6ffed; color: #52c41a; }
|
||||
.status-offline { background: #fff2f0; color: #ff4d4f; }
|
||||
.btn-edit { color: var(--primary); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
||||
.btn-del { color: var(--main-red); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
||||
.empty { text-align: center; padding: 40px; color: #999; }
|
||||
.icon-cell { font-size: 24px; }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal-content { background: #fff; padding: 30px; border-radius: 20px; width: 90%; max-width: 550px; max-height: 85vh; overflow-y: auto; }
|
||||
.input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 8px; box-sizing: border-box; margin-bottom: 12px; font-size: 14px; }
|
||||
.input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||
select.input { appearance: none; -webkit-appearance: none; background: #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat right 12px center/12px; }
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row > div { flex: 1; }
|
||||
label { font-size: 13px; color: #666; margin-bottom: 4px; display: block; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.checkbox-row input[type="checkbox"] { width: 16px; height: 16px; }
|
||||
.modal-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 12px; }
|
||||
.header { flex-direction: column; gap: 12px; text-align: center; padding: 15px; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .btn { flex: 1; text-align: center; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
/* 手机端隐藏状态和排序列 */
|
||||
th:nth-child(3), td:nth-child(3),
|
||||
th:nth-child(4), td:nth-child(4),
|
||||
th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
th, td { padding: 10px 8px; font-size: 13px; }
|
||||
.icon-cell { font-size: 20px; }
|
||||
.modal-content { padding: 20px; border-radius: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📡 服务管理</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 新增服务</button>
|
||||
<a href="/admin/dashboard" class="btn btn-back">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>图标</th>
|
||||
<th>名称</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>排序</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
<div id="emptyMsg" class="empty" style="display:none">暂无服务,点击上方"+ 新增服务"添加</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<div id="modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<h2 id="modalTitle" style="margin-bottom:20px">新增服务</h2>
|
||||
<input type="hidden" id="editId">
|
||||
|
||||
<label>服务名称 *</label>
|
||||
<input type="text" id="svcName" class="input" placeholder="如:Gitea" required>
|
||||
|
||||
<label>服务地址 *</label>
|
||||
<input type="url" id="svcUrl" class="input" placeholder="https://example.com">
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>图标(emoji)</label>
|
||||
<input type="text" id="svcIcon" class="input" placeholder="🔧" maxlength="4">
|
||||
</div>
|
||||
<div>
|
||||
<label>所属分类</label>
|
||||
<select id="svcCategory" class="input">
|
||||
<option value="0">-- 无分类 --</option>
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>描述</label>
|
||||
<input type="text" id="svcDesc" class="input" placeholder="简短描述">
|
||||
|
||||
<label>标签(逗号分隔)</label>
|
||||
<input type="text" id="svcTags" class="input" placeholder="开发,工具">
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>排序</label>
|
||||
<input type="number" id="svcOrder" class="input" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label>健康检查 URL</label>
|
||||
<input type="url" id="svcHealthUrl" class="input" placeholder="可选">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="svcEnabled" checked>
|
||||
<label for="svcEnabled" style="margin:0">启用</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="svcHealthEnabled">
|
||||
<label for="svcHealthEnabled" style="margin:0">启用健康检查</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:#eee" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="save()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 分类映射
|
||||
const categoryMap = {};
|
||||
{{ range .categories }}
|
||||
categoryMap[{{ .ID }}] = "{{ .Name }}";
|
||||
{{ end }}
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/admin/api/services');
|
||||
const data = await res.json();
|
||||
const list = document.getElementById('list');
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
list.innerHTML = '';
|
||||
emptyMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
list.innerHTML = data.data.map(s => `
|
||||
<tr>
|
||||
<td class="icon-cell">${s.icon || '🔗'}</td>
|
||||
<td><strong>${escapeHTML(s.name)}</strong></td>
|
||||
<td>${categoryMap[s.category_id] || '-'}</td>
|
||||
<td><span class="status-badge status-${s.status}">${s.status === 'online' ? '在线' : '离线'}</span></td>
|
||||
<td>${s.sort_order}</td>
|
||||
<td>
|
||||
<button class="btn-edit" onclick='editSvc(${JSON.stringify(s)})'>编辑</button>
|
||||
<button class="btn-del" onclick="del(${s.id}, '${escapeHTML(s.name)}')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('editId').value = '';
|
||||
document.getElementById('svcName').value = '';
|
||||
document.getElementById('svcUrl').value = '';
|
||||
document.getElementById('svcIcon').value = '';
|
||||
document.getElementById('svcCategory').value = '0';
|
||||
document.getElementById('svcDesc').value = '';
|
||||
document.getElementById('svcTags').value = '';
|
||||
document.getElementById('svcOrder').value = '0';
|
||||
document.getElementById('svcHealthUrl').value = '';
|
||||
document.getElementById('svcEnabled').checked = true;
|
||||
document.getElementById('svcHealthEnabled').checked = false;
|
||||
document.getElementById('modalTitle').textContent = '新增服务';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
document.getElementById('svcName').focus();
|
||||
}
|
||||
|
||||
function editSvc(s) {
|
||||
document.getElementById('editId').value = s.id;
|
||||
document.getElementById('svcName').value = s.name;
|
||||
document.getElementById('svcUrl').value = s.url;
|
||||
document.getElementById('svcIcon').value = s.icon || '';
|
||||
document.getElementById('svcCategory').value = s.category_id || '0';
|
||||
document.getElementById('svcDesc').value = s.description || '';
|
||||
document.getElementById('svcTags').value = s.tags || '';
|
||||
document.getElementById('svcOrder').value = s.sort_order || 0;
|
||||
document.getElementById('svcHealthUrl').value = s.health_check_url || '';
|
||||
document.getElementById('svcEnabled').checked = s.is_enabled;
|
||||
document.getElementById('svcHealthEnabled').checked = s.health_check_enabled;
|
||||
document.getElementById('modalTitle').textContent = '编辑服务';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const id = document.getElementById('editId').value;
|
||||
const name = document.getElementById('svcName').value.trim();
|
||||
const url = document.getElementById('svcUrl').value.trim();
|
||||
|
||||
if (!name) { alert('服务名称不能为空'); return; }
|
||||
if (!url) { alert('服务地址不能为空'); return; }
|
||||
|
||||
const body = {
|
||||
name,
|
||||
url,
|
||||
icon: document.getElementById('svcIcon').value.trim(),
|
||||
category_id: parseInt(document.getElementById('svcCategory').value) || 0,
|
||||
description: document.getElementById('svcDesc').value.trim(),
|
||||
tags: document.getElementById('svcTags').value.trim(),
|
||||
sort_order: parseInt(document.getElementById('svcOrder').value) || 0,
|
||||
health_check_url: document.getElementById('svcHealthUrl').value.trim(),
|
||||
is_enabled: document.getElementById('svcEnabled').checked,
|
||||
health_check_enabled: document.getElementById('svcHealthEnabled').checked,
|
||||
};
|
||||
if (id) body.id = parseInt(id);
|
||||
|
||||
const apiUrl = id ? `/admin/api/services/${id}` : '/admin/api/services';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
closeModal();
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function del(id, name) {
|
||||
if (!confirm(`确定删除服务「${name}」?`)) return;
|
||||
const resp = await fetch('/admin/api/services/' + id, { method: 'DELETE' });
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Services - ToNav</title><link rel="stylesheet" href="/static/css/admin.css"><script>document.documentElement.classList.toggle('dark',localStorage.getItem('tonav-theme')==='dark')</script></head><body><div class="admin-shell"><aside class="admin-sidebar"><div class="admin-brand">ToNav</div><div class="admin-label">OVERVIEW</div><a class="admin-nav" href="/admin/dashboard">Overview</a><div class="admin-label" style="margin-top:22px">SERVICES</div><a class="admin-nav active" href="/admin/services">Services</a><a class="admin-nav" href="/admin/categories">Categories</a><div class="admin-label" style="margin-top:22px">SYSTEM</div><a class="admin-nav" href="/admin/dashboard#settings">Settings</a><div class="admin-sidebar-foot"><a href="/">← Open navigation</a><br><a href="/admin/logout">Sign out</a></div></aside><main class="admin-content"><div class="admin-main"><header class="admin-topbar"><div><h1>Services</h1><div class="admin-sub">Manage entries in your service directory</div></div><div class="top-actions"><a class="btn" href="/">Open site</a><button class="btn btn-primary" onclick="openModal()">+ Add service</button></div></header><section class="section"><div class="table-toolbar"><input id="filter" class="search-input" placeholder="Search services..." oninput="load()"></div><div class="panel table-wrap"><table class="data-table"><thead><tr><th>Service</th><th>Category</th><th>Status</th><th>Order</th><th>Actions</th></tr></thead><tbody id="list"></tbody></table><div id="emptyMsg" class="empty-state" style="display:none">No services found.</div></div></section></div></main></div>
|
||||
<div id="modal" class="modal-overlay"><div class="modal-content"><div class="modal-head"><h2 id="modalTitle">Add service</h2><button class="close" onclick="closeModal()">×</button></div><input type="hidden" id="editId"><div class="form-group"><label>Name *</label><input id="svcName" class="input"></div><div class="form-group"><label>URL *</label><input id="svcUrl" type="url" class="input" placeholder="https://example.com"></div><div class="form-row"><div class="form-group"><label>Icon</label><input id="svcIcon" class="input" maxlength="8" placeholder="◎"></div><div class="form-group"><label>Category</label><select id="svcCategory" class="input"><option value="0">No category</option>{{ range .categories }}<option value="{{ .ID }}">{{ .Name }}</option>{{ end }}</select></div></div><div class="form-group"><label>Description</label><input id="svcDesc" class="input"></div><div class="form-group"><label>Tags (comma separated)</label><input id="svcTags" class="input"></div><div class="form-row"><div class="form-group"><label>Sort order</label><input id="svcOrder" type="number" class="input" value="0"></div><div class="form-group"><label>Health check URL</label><input id="svcHealthUrl" type="url" class="input"></div></div><div class="form-group"><label><input type="checkbox" id="svcEnabled" checked> Enabled</label></div><div class="form-group"><label><input type="checkbox" id="svcHealthEnabled"> Health check enabled</label></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="save()">Save</button></div></div></div>
|
||||
<script>const categoryMap={};{{ range .categories }}categoryMap[{{ .ID }}]={{ printf "%q" .Name }};{{ end }}
|
||||
async function load(){const r=await fetch('/admin/api/services'),d=await r.json(),q=filter.value.toLowerCase();const rows=(d.data||[]).filter(s=>!q||[s.name,s.description,s.tags,s.url].some(x=>(x||'').toLowerCase().includes(q)));emptyMsg.style.display=rows.length?'none':'block';list.innerHTML=rows.map(s=>`<tr><td><div style="display:flex;align-items:center;gap:10px"><span class="service-icon">${escapeHTML(s.icon||'◎')}</span><span><strong>${escapeHTML(s.name)}</strong><small style="display:block;color:var(--muted)">${escapeHTML(s.url)}</small></span></div></td><td>${escapeHTML(categoryMap[s.category_id]||'—')}</td><td><span class="status-inline"><i class="dot ${s.status}"></i>${s.status==='online'?'Online':s.status==='offline'?'Offline':'Unknown'}</span></td><td>${s.sort_order}</td><td><button class="btn" onclick='editSvc(${JSON.stringify(s)})'>Edit</button> <button class="btn btn-danger" onclick="del(${s.id},'${escapeHTML(s.name)}')">Delete</button></td></tr>`).join('')}
|
||||
function openModal(){editId.value='';svcName.value='';svcUrl.value='';svcIcon.value='';svcCategory.value='0';svcDesc.value='';svcTags.value='';svcOrder.value='0';svcHealthUrl.value='';svcEnabled.checked=true;svcHealthEnabled.checked=false;modalTitle.textContent='Add service';modal.style.display='flex';svcName.focus()}
|
||||
function editSvc(s){editId.value=s.id;svcName.value=s.name;svcUrl.value=s.url;svcIcon.value=s.icon||'';svcCategory.value=s.category_id||'0';svcDesc.value=s.description||'';svcTags.value=s.tags||'';svcOrder.value=s.sort_order||0;svcHealthUrl.value=s.health_check_url||'';svcEnabled.checked=s.is_enabled;svcHealthEnabled.checked=s.health_check_enabled;modalTitle.textContent='Edit service';modal.style.display='flex'}function closeModal(){modal.style.display='none'}
|
||||
async function save(){const id=editId.value,name=svcName.value.trim(),url=svcUrl.value.trim();if(!name||!url)return alert('Name and URL are required');const body={name,url,icon:svcIcon.value.trim(),category_id:+svcCategory.value||0,description:svcDesc.value.trim(),tags:svcTags.value.trim(),sort_order:+svcOrder.value||0,health_check_url:svcHealthUrl.value.trim(),is_enabled:svcEnabled.checked,health_check_enabled:svcHealthEnabled.checked};if(id)body.id=+id;const r=await fetch(id?'/admin/api/services/'+id:'/admin/api/services',{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}),d=await r.json();if(d.success){closeModal();load()}else alert(d.message||'Save failed')}
|
||||
async function del(id,name){if(!confirm('Delete '+name+'?'))return;const r=await fetch('/admin/api/services/'+id,{method:'DELETE'}),d=await r.json();if(d.success)load();else alert(d.message||'Delete failed')}
|
||||
function escapeHTML(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}function toggleTheme(){const dark=!document.documentElement.classList.contains('dark');document.documentElement.classList.toggle('dark',dark);localStorage.setItem('tonav-theme',dark?'dark':'light')}document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});load();</script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user