feat: add Chinese and English UI language switch
This commit is contained in:
@@ -14,6 +14,15 @@ import (
|
||||
)
|
||||
|
||||
// GetSettings 获取设置
|
||||
func GetLanguage(c *gin.Context) {
|
||||
var setting models.Setting
|
||||
lang := "en"
|
||||
if err := database.DB.Where("key = ?", "language").First(&setting).Error; err == nil && setting.Value != "" {
|
||||
lang = setting.Value
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"language": lang})
|
||||
}
|
||||
|
||||
func GetSettings(c *gin.Context) {
|
||||
var settings []models.Setting
|
||||
database.DB.Find(&settings)
|
||||
|
||||
@@ -36,6 +36,7 @@ func main() {
|
||||
)
|
||||
|
||||
r.GET("/", handlers.IndexHandler)
|
||||
r.GET("/api/language", handlers.GetLanguage)
|
||||
|
||||
r.GET("/admin/login", func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "login.html", nil)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
(function(){
|
||||
const zh={
|
||||
"OVERVIEW":"概览","SERVICES":"服务","SYSTEM":"系统","Admin sign in":"管理员登录","Overview":"概览","Service directory and system status":"服务目录与系统状态","Services":"服务","Manage service entries":"管理服务条目","Categories":"分类","Organize your directory":"整理服务目录","Settings":"设置","Site, backup and MCP":"站点、备份与 MCP","Backup":"备份","Run cloud backup now":"立即执行云端备份","System status":"系统状态","Quick actions":"快捷操作","Open navigation":"打开导航","Sign out":"退出登录","Open site":"打开网站","Theme":"主题",
|
||||
"Administration":"管理后台","Username":"用户名","Password":"密码","Sign in":"登录","Current password":"当前密码","New password":"新密码","Confirm new password":"确认新密码","Update password":"更新密码","For security, the initial password must be changed before continuing.":"为了安全,继续操作前必须修改初始密码。",
|
||||
"Service directory":"服务目录","Search services, tags, descriptions…":"搜索服务、标签、描述…","All":"全部","Uncategorized":"未分类","No services found":"没有找到服务","Loading…":"加载中…","System settings":"系统设置","Site title":"站点标题","WebDAV URL":"WebDAV 地址","Daily backup at 03:00":"每天 03:00 自动备份","MCP Server":"MCP 服务","Enable MCP endpoint":"启用 MCP 接口","Copy":"复制","Copied":"已复制","Regenerate":"重新生成","Cloud backups":"云端备份","No cloud backups":"暂无云端备份","Restore":"恢复","Delete":"删除","Close":"关闭","Save changes":"保存更改","Save":"保存","Cancel":"取消",
|
||||
"Manage entries in your service directory":"管理服务目录中的条目","Search services...":"搜索服务…","Service":"服务","Category":"分类","Status":"状态","Order":"排序","Actions":"操作","Add service":"添加服务","Edit service":"编辑服务","Name *":"名称 *","URL *":"网址 *","Icon":"图标","No category":"无分类","Description":"描述","Tags (comma separated)":"标签(逗号分隔)","Sort order":"排序值","Health check URL":"健康检查地址","Enabled":"启用","Health check enabled":"启用健康检查",
|
||||
"Add category":"添加分类","Edit category":"编辑分类","Organize services into groups":"将服务整理到不同分类","No categories yet.":"暂无分类。","Name":"名称","Save failed":"保存失败","Delete failed":"删除失败","Name is required":"名称不能为空","Name and URL are required":"名称和网址不能为空","Online":"在线","Offline":"离线","Unknown":"未知",
|
||||
"POST/GET /mcp · changes apply immediately":"POST/GET /mcp · 修改立即生效","New token:":"新 Token:","Regenerate token? The old token will stop working.":"重新生成 Token?旧 Token 将立即失效。","Run cloud backup now?":"立即执行云端备份?","Current database will be backed up first.":"当前数据库会先进行备份。","Unable to load backups":"无法加载备份","English":"English","Chinese (Simplified)":"简体中文","Chinese":"中文","Language":"语言","Language preference":"语言偏好","Saved":"已保存","Backup complete":"备份完成","Done":"完成","Failed":"失败","Copy failed: ":"复制失败:","Delete ":"删除 ","Restore ":"恢复 ","?":"?"
|
||||
};
|
||||
function tr(s){const v=(s||"").trim();if(/^\d+ services$/.test(v))return v.replace(/^(\d+) services$/,"$1 个服务");if(/^order \d+$/.test(v))return v.replace(/^order (\d+)$/,"排序 $1");return zh[v]||null}
|
||||
function translate(root=document){
|
||||
const w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT),a=[];while(w.nextNode())a.push(w.currentNode);
|
||||
a.forEach(n=>{const x=tr(n.nodeValue);if(x)n.nodeValue=n.nodeValue.replace(n.nodeValue.trim(),x)});
|
||||
root.querySelectorAll?.("input[placeholder],textarea[placeholder],button[title],a[title]").forEach(e=>{for(const k of ["placeholder","title"]){if(e.hasAttribute(k)){const x=tr(e.getAttribute(k));if(x)e.setAttribute(k,x)}}});
|
||||
const x=tr(document.title);if(x)document.title=x;
|
||||
}
|
||||
function apply(lang){document.documentElement.lang=lang==="zh-CN"?"zh-CN":"en";if(lang==="zh-CN")translate(document)}
|
||||
async function init(){try{const endpoint=location.pathname.startsWith("/admin")?"/admin/api/settings":"/api/language";const r=await fetch(endpoint,{credentials:"same-origin"});const d=await r.json();window.tonavLanguage=d.language||"en";apply(window.tonavLanguage);window.tonavTranslate=()=>translate(document);document.dispatchEvent(new CustomEvent("tonav-language",{detail:window.tonavLanguage}))}catch(e){}}
|
||||
window.tonavSetLanguage=apply;window.t=function(s){return window.tonavLanguage==="zh-CN"?tr(s)||s:s};init();
|
||||
new MutationObserver(ms=>{if(window.tonavLanguage==="zh-CN")ms.forEach(m=>m.addedNodes.forEach(n=>{if(n.nodeType===1)translate(n)}))}).observe(document.documentElement,{childList:true,subtree:true});
|
||||
})();
|
||||
@@ -4,7 +4,7 @@
|
||||
async function load(){const r=await fetch('/admin/api/categories'),d=await r.json();if(!d.data?.length){list.innerHTML='<div class="empty-state">No categories yet.</div>';return}list.innerHTML=d.data.map(c=>`<div style="display:flex;align-items:center;justify-content:space-between;padding:14px;border-bottom:1px solid var(--line)"><div><strong>${escapeHTML(c.name)}</strong><span class="tag" style="margin-left:9px">order ${c.sort_order}</span></div><div><button class="btn" onclick='edit(${JSON.stringify(c)})'>Edit</button> <button class="btn btn-danger" onclick="del(${c.id},'${escapeHTML(c.name)}')">Delete</button></div></div>`).join('')}
|
||||
function openModal(id,name,order){editId.value=id||'';catName.value=name||'';catOrder.value=order||0;modalTitle.textContent=id?'Edit category':'Add category';modal.style.display='flex';catName.focus()}
|
||||
function edit(c){openModal(c.id,c.name,c.sort_order)}function closeModal(){modal.style.display='none'}
|
||||
async function save(){const id=editId.value,name=catName.value.trim();if(!name)return alert('Name is required');const body={name,sort_order:+catOrder.value||0};if(id)body.id=+id;const r=await fetch(id?'/admin/api/categories/'+id:'/admin/api/categories',{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/categories/'+id,{method:'DELETE'}),d=await r.json();if(d.success)load();else alert(d.message||'Delete failed')}
|
||||
async function save(){const id=editId.value,name=catName.value.trim();if(!name)return alert(t('Name is required'));const body={name,sort_order:+catOrder.value||0};if(id)body.id=+id;const r=await fetch(id?'/admin/api/categories/'+id:'/admin/api/categories',{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||t('Save failed'))}
|
||||
async function del(id,name){if(!confirm(t('Delete ')+name+t('?')))return;const r=await fetch('/admin/api/categories/'+id,{method:'DELETE'}),d=await r.json();if(d.success)load();else alert(d.message||t('Delete failed'))}
|
||||
function escapeHTML(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});load();
|
||||
</script></body></html>
|
||||
</script><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Change password - 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="login-shell"><div class="login-box"><div class="login-brand">Change password</div><div class="login-sub">Update your administrator credentials</div><div class="alert">For security, the initial password must be changed before continuing.</div>{{ if .error }}<div class="error-box">{{ .error }}</div>{{ end }}<form method="POST"><div class="form-group"><label>Current password</label><input class="input" type="password" name="old_password" required autocomplete="current-password"></div><div class="form-group"><label>New password</label><input class="input" type="password" name="new_password" minlength="6" required autocomplete="new-password"></div><div class="form-group"><label>Confirm new password</label><input class="input" type="password" name="confirm_password" minlength="6" required autocomplete="new-password"></div><button class="btn btn-primary" type="submit">Update password</button></form></div></div></body></html>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Change password - 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="login-shell"><div class="login-box"><div class="login-brand">Change password</div><div class="login-sub">Update your administrator credentials</div><div class="alert">For security, the initial password must be changed before continuing.</div>{{ if .error }}<div class="error-box">{{ .error }}</div>{{ end }}<form method="POST"><div class="form-group"><label>Current password</label><input class="input" type="password" name="old_password" required autocomplete="current-password"></div><div class="form-group"><label>New password</label><input class="input" type="password" name="new_password" minlength="6" required autocomplete="new-password"></div><div class="form-group"><label>Confirm new password</label><input class="input" type="password" name="confirm_password" minlength="6" required autocomplete="new-password"></div><button class="btn btn-primary" type="submit">Update password</button></form></div></div><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div></main></div>
|
||||
<div id="settingsModal" class="modal-overlay"><div class="modal-content">
|
||||
<div class="modal-head"><h2>System settings</h2><button class="close" onclick="closeSettings()">×</button></div>
|
||||
<div class="form-group"><label>Site title</label><input id="cfg_site_title" class="input"></div>
|
||||
<div class="form-group"><label>Language</label><select id="cfg_language" class="input"><option value="en">English</option><option value="zh-CN">Chinese (Simplified)</option></select></div><div class="form-group"><label>Site title</label><input id="cfg_site_title" class="input"></div>
|
||||
<div class="form-group"><label>WebDAV URL</label><input id="cfg_url" class="input"></div>
|
||||
<div class="form-row"><div class="form-group"><label>Username</label><input id="cfg_user" class="input"></div><div class="form-group"><label>Password</label><input id="cfg_pass" type="password" class="input"></div></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="cfg_auto_backup"> Daily backup at 03:00</label></div>
|
||||
@@ -42,17 +42,17 @@
|
||||
<script>
|
||||
function toggleTheme(){const dark=!document.documentElement.classList.contains('dark');document.documentElement.classList.toggle('dark',dark);localStorage.setItem('tonav-theme',dark?'dark':'light')}
|
||||
function closeSettings(){document.getElementById('settingsModal').style.display='none'}
|
||||
async function showSettings(){const r=await fetch('/admin/api/settings'),d=await r.json();cfg_site_title.value=d.site_title||'';cfg_url.value=d.webdav_url||'';cfg_user.value=d.webdav_user||'';cfg_pass.value=d.webdav_password||'';cfg_auto_backup.checked=d.auto_backup==='true';document.getElementById('settingsModal').style.display='flex';loadMCPStatus();loadCloudBackups()}
|
||||
async function showSettings(){const r=await fetch('/admin/api/settings'),d=await r.json();cfg_language.value=d.language||'en';cfg_site_title.value=d.site_title||'';cfg_url.value=d.webdav_url||'';cfg_user.value=d.webdav_user||'';cfg_pass.value=d.webdav_password||'';cfg_auto_backup.checked=d.auto_backup==='true';document.getElementById('settingsModal').style.display='flex';loadMCPStatus();loadCloudBackups()}
|
||||
async function loadMCPStatus(){try{const r=await fetch('/admin/api/mcp/status'),d=await r.json();cfg_mcp_enabled.checked=d.enabled;mcpTokenRow.style.display=d.enabled?'block':'none';cfg_mcp_token.value=d.token_masked||'Not generated'}catch(e){}}
|
||||
async function toggleMCP(){const enabled=cfg_mcp_enabled.checked;try{const r=await fetch('/admin/api/mcp/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})}),d=await r.json();if(!d.success)throw Error(d.message)}catch(e){alert(e.message||'Failed');cfg_mcp_enabled.checked=!enabled;mcpTokenRow.style.display=enabled?'none':'block'}}
|
||||
async function copyMCPToken(btn){try{const r=await fetch('/admin/api/mcp/token'),d=await r.json();if(!d.success)throw Error(d.message);await navigator.clipboard.writeText(d.token);const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>btn.textContent=old,1200)}catch(e){alert('Copy failed: '+e.message)}}
|
||||
async function regenMCPToken(){if(!confirm('Regenerate token? The old token will stop working.'))return;const r=await fetch('/admin/api/mcp/token',{method:'POST'}),d=await r.json();if(d.success){alert('New token:\n\n'+d.token);loadMCPStatus()}else alert(d.message)}
|
||||
async function toggleMCP(){const enabled=cfg_mcp_enabled.checked;try{const r=await fetch('/admin/api/mcp/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})}),d=await r.json();if(!d.success)throw Error(d.message)}catch(e){alert(e.message||t('Failed'));cfg_mcp_enabled.checked=!enabled;mcpTokenRow.style.display=enabled?'none':'block'}}
|
||||
async function copyMCPToken(btn){try{const r=await fetch('/admin/api/mcp/token'),d=await r.json();if(!d.success)throw Error(d.message);await navigator.clipboard.writeText(d.token);const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>btn.textContent=old,1200)}catch(e){alert(t('Copy failed: ') + e.message)}}
|
||||
async function regenMCPToken(){if(!confirm(t('Regenerate token? The old token will stop working.')))return;const r=await fetch('/admin/api/mcp/token',{method:'POST'}),d=await r.json();if(d.success){alert(t('New token:')+'\n\n'+d.token);loadMCPStatus()}else alert(d.message)}
|
||||
async function loadCloudBackups(){const el=backupList;try{const r=await fetch('/admin/api/backup/list'),d=await r.json();if(!d.files?.length){el.textContent='No cloud backups';return}el.innerHTML=d.files.map(f=>`<div style="padding:8px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:10px"><span>${f.name}<small style="display:block;color:var(--faint)">${formatTime(f.mod_time)} · ${formatSize(f.size)}</small></span><span><button class="btn" onclick="restoreBackup('${encodeURIComponent(f.name)}')">Restore</button> <button class="btn btn-danger" onclick="deleteBackup('${encodeURIComponent(f.name)}')">Delete</button></span></div>`).join('')}catch(e){el.textContent='Unable to load backups'}}
|
||||
function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
|
||||
function formatTime(s){if(!s)return'';const d=new Date(s);return isNaN(d)?s:d.toLocaleString('zh-CN')}
|
||||
async function saveSettings(){const data={site_title:cfg_site_title.value,webdav_url:cfg_url.value,webdav_user:cfg_user.value,webdav_password:cfg_pass.value,auto_backup:cfg_auto_backup.checked?'true':'false'};const r=await fetch('/admin/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}),d=await r.json();alert(d.message||'Saved')}
|
||||
async function runBackup(){if(!confirm('Run cloud backup now?'))return;const r=await fetch('/admin/api/backup/webdav',{method:'POST'}),d=await r.json();alert(d.message||'Backup complete')}
|
||||
async function restoreBackup(name){name=decodeURIComponent(name);if(!confirm('Restore '+name+'? Current database will be backed up first.'))return;const r=await fetch('/admin/api/backup/restore?name='+encodeURIComponent(name),{method:'POST'}),d=await r.json();alert(d.message||'Done');if(d.success)location.reload()}
|
||||
async function deleteBackup(name){name=decodeURIComponent(name);if(!confirm('Delete '+name+'?'))return;const r=await fetch('/admin/api/backup/delete?name='+encodeURIComponent(name),{method:'DELETE'}),d=await r.json();if(d.success)loadCloudBackups();else alert(d.message)}
|
||||
async function saveSettings(){const data={language:cfg_language.value,site_title:cfg_site_title.value,webdav_url:cfg_url.value,webdav_user:cfg_user.value,webdav_password:cfg_pass.value,auto_backup:cfg_auto_backup.checked?'true':'false'};const r=await fetch('/admin/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}),d=await r.json();alert(d.message||t('Saved'));if(d.success){location.reload()}}
|
||||
async function runBackup(){if(!confirm(t('Run cloud backup now?')))return;const r=await fetch('/admin/api/backup/webdav',{method:'POST'}),d=await r.json();alert(d.message||t('Backup complete'))}
|
||||
async function restoreBackup(name){name=decodeURIComponent(name);if(!confirm(t('Restore ')+name+t('? Current database will be backed up first.')))return;const r=await fetch('/admin/api/backup/restore?name='+encodeURIComponent(name),{method:'POST'}),d=await r.json();alert(d.message||t('Done'));if(d.success)location.reload()}
|
||||
async function deleteBackup(name){name=decodeURIComponent(name);if(!confirm(t('Delete ')+name+t('?')))return;const r=await fetch('/admin/api/backup/delete?name='+encodeURIComponent(name),{method:'DELETE'}),d=await r.json();if(d.success)loadCloudBackups();else alert(d.message)}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeSettings()});
|
||||
</script></body></html>
|
||||
</script><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in - 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="login-shell"><div class="login-box"><div class="login-brand">ToNav</div><div class="login-sub">Administration</div>{{ if .error }}<div class="error-box">{{ .error }}</div>{{ end }}<form method="POST"><div class="form-group"><label>Username</label><input class="input" type="text" name="username" required autofocus autocomplete="username"></div><div class="form-group"><label>Password</label><input class="input" type="password" name="password" required autocomplete="current-password"></div><button class="btn btn-primary" type="submit">Sign in</button></form></div></div></body></html>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in - 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="login-shell"><div class="login-box"><div class="login-brand">ToNav</div><div class="login-sub">Administration</div>{{ if .error }}<div class="error-box">{{ .error }}</div>{{ end }}<form method="POST"><div class="form-group"><label>Username</label><input class="input" type="text" name="username" required autofocus autocomplete="username"></div><div class="form-group"><label>Password</label><input class="input" type="password" name="password" required autocomplete="current-password"></div><button class="btn btn-primary" type="submit">Sign in</button></form></div></div><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
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>
|
||||
async function save(){const id=editId.value,name=svcName.value.trim(),url=svcUrl.value.trim();if(!name||!url)return alert(t('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||t('Save failed'))}
|
||||
async function del(id,name){if(!confirm(t('Delete ')+name+t('?')))return;const r=await fetch('/admin/api/services/'+id,{method:'DELETE'}),d=await r.json();if(d.success)load();else alert(d.message||t('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><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
<script>const categoriesData={{ .categories_json }};const servicesData={{ .services_json }};let currentTab='all',currentKeyword='';const map={};categoriesData.forEach(c=>map[c.id]=c.name);const esc=s=>{const d=document.createElement('div');d.textContent=s||'';return d.innerHTML};
|
||||
function renderNav(){const n=document.getElementById('categoryNav');const m=document.getElementById('mobileCategories');document.getElementById('allCount').textContent=servicesData.length;const items=categoriesData.map(c=>`<button class="nav-item" data-category="${esc(c.name)}">${esc(c.name)} <span>${servicesData.filter(s=>s.category_id===c.id).length}</span></button>`).join('');n.innerHTML=items;m.innerHTML=`<button class="mobile-category active" data-category="all">All <span>${servicesData.length}</span></button>`+categoriesData.map(c=>`<button class="mobile-category" data-category="${esc(c.name)}">${esc(c.name)} <span>${servicesData.filter(s=>s.category_id===c.id).length}</span></button>`).join('');document.querySelectorAll('.nav-item,.mobile-category').forEach(b=>b.onclick=()=>{currentTab=b.dataset.category;document.querySelectorAll('.nav-item,.mobile-category').forEach(x=>x.classList.toggle('active',x.dataset.category===currentTab));render()})}
|
||||
function render(){let a=currentTab==='all'?servicesData:servicesData.filter(s=>map[s.category_id]===currentTab);if(currentKeyword)a=a.filter(s=>(s.name+' '+(s.description||'')+' '+(s.tags||'')).toLowerCase().includes(currentKeyword));document.getElementById('resultCount').textContent=`${a.length} services`;document.getElementById('servicesGrid').innerHTML=a.length?a.map(s=>`<a class="service-row" href="${esc(s.url)}" target="_blank" rel="noopener"><span class="service-icon">${esc(s.icon)||'↗'}</span><span class="service-main"><strong>${esc(s.name)}</strong><span>${esc(s.description||s.url)}</span></span><span class="service-meta"><span class="status ${s.status==='online'?'online':s.status==='offline'?'offline':''}"></span>${esc(map[s.category_id]||'Uncategorized')}</span><span class="service-arrow">→</span></a>`).join(''):'<div class="empty-state">No services found</div>'}
|
||||
document.getElementById('searchInput').oninput=e=>{currentKeyword=e.target.value.toLowerCase().trim();render()};document.addEventListener('keydown',e=>{if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){e.preventDefault();document.getElementById('searchInput').focus()}});document.getElementById('themeBtn').onclick=()=>{document.documentElement.classList.toggle('dark');localStorage.setItem('tonav-theme',document.documentElement.classList.contains('dark')?'dark':'light')};if(localStorage.getItem('tonav-theme')==='dark')document.documentElement.classList.add('dark');renderNav();render();document.getElementById('lastCheckTime').textContent=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});</script></body></html>
|
||||
document.getElementById('searchInput').oninput=e=>{currentKeyword=e.target.value.toLowerCase().trim();render()};document.addEventListener('keydown',e=>{if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){e.preventDefault();document.getElementById('searchInput').focus()}});document.getElementById('themeBtn').onclick=()=>{document.documentElement.classList.toggle('dark');localStorage.setItem('tonav-theme',document.documentElement.classList.contains('dark')?'dark':'light')};if(localStorage.getItem('tonav-theme')==='dark')document.documentElement.classList.add('dark');renderNav();render();document.getElementById('lastCheckTime').textContent=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});</script><script src="/static/js/i18n.js"></script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user