Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24e598446 | ||
|
|
2464222c94 | ||
|
|
ad6d817f19 | ||
|
|
6921211dcd | ||
|
|
c512a4c525 | ||
|
|
640ae4743f | ||
|
|
e1e0bda7c9 | ||
|
|
528db25833 | ||
|
|
68e3d0bfce | ||
|
|
32daa27712 | ||
|
|
8cf91aed9a |
+18
-12
@@ -379,6 +379,13 @@ func toolAddService(args map[string]interface{}) (interface{}, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagsStr := ""
|
||||||
|
if rawTags, ok := args["tags"]; ok && rawTags != nil {
|
||||||
|
if arr, ok := rawTags.([]interface{}); ok {
|
||||||
|
tagsStr = tagsToString(arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svc := models.Service{
|
svc := models.Service{
|
||||||
Name: strings.TrimSpace(name),
|
Name: strings.TrimSpace(name),
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -386,22 +393,11 @@ func toolAddService(args map[string]interface{}) (interface{}, error) {
|
|||||||
Icon: icon,
|
Icon: icon,
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Status: "unknown",
|
Status: "unknown",
|
||||||
Tags: tagsToString(args["tags"].([]interface{})),
|
Tags: tagsStr,
|
||||||
IsEnabled: isEnabled,
|
IsEnabled: isEnabled,
|
||||||
HealthCheckURL: strings.TrimSpace(healthURL),
|
HealthCheckURL: strings.TrimSpace(healthURL),
|
||||||
HealthCheckEnabled: healthEnabled,
|
HealthCheckEnabled: healthEnabled,
|
||||||
}
|
}
|
||||||
// tags 需要单独处理(args["tags"] 可能不存在)
|
|
||||||
if rawTags, ok := args["tags"]; ok {
|
|
||||||
if arr, ok := rawTags.([]interface{}); ok {
|
|
||||||
svc.Tags = tagsToString(arr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if svc.Tags == "" {
|
|
||||||
if arr, ok := args["tags"].([]interface{}); ok {
|
|
||||||
svc.Tags = tagsToString(arr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.DB.Create(&svc).Error; err != nil {
|
if err := database.DB.Create(&svc).Error; err != nil {
|
||||||
return nil, fmt.Errorf("创建失败: %v", err)
|
return nil, fmt.Errorf("创建失败: %v", err)
|
||||||
@@ -797,6 +793,16 @@ func MCPStatus(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCPGetToken 返回完整明文 Token(供后台「复制」按钮使用,需已登录后台会话)
|
||||||
|
func MCPGetToken(c *gin.Context) {
|
||||||
|
_, token := getMCPAuthState()
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Token 未生成,请先点「重新生成」"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "token": token})
|
||||||
|
}
|
||||||
|
|
||||||
// MCPRegenerateToken 生成新随机 Token 并自动启用 MCP,返回完整新 Token(仅此一次明文展示)
|
// MCPRegenerateToken 生成新随机 Token 并自动启用 MCP,返回完整新 Token(仅此一次明文展示)
|
||||||
func MCPRegenerateToken(c *gin.Context) {
|
func MCPRegenerateToken(c *gin.Context) {
|
||||||
b := make([]byte, 24)
|
b := make([]byte, 24)
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// GetSettings 获取设置
|
// 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) {
|
func GetSettings(c *gin.Context) {
|
||||||
var settings []models.Setting
|
var settings []models.Setting
|
||||||
database.DB.Find(&settings)
|
database.DB.Find(&settings)
|
||||||
@@ -78,10 +87,38 @@ func RunCloudBackup(c *gin.Context) {
|
|||||||
|
|
||||||
// 清理本地旧备份,保留最近5份
|
// 清理本地旧备份,保留最近5份
|
||||||
config.CleanOldBackups(5)
|
config.CleanOldBackups(5)
|
||||||
|
if err := cleanOldRemoteBackups(client, 5); err != nil {
|
||||||
|
log.Printf("云端旧备份清理失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, Response{Success: true, Message: "备份成功: " + remoteName})
|
c.JSON(http.StatusOK, Response{Success: true, Message: "备份成功: " + remoteName})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanOldRemoteBackups 清理 WebDAV 上的旧备份,仅保留最近 keep 份。
|
||||||
|
func cleanOldRemoteBackups(client *config.WebDAVClient, keep int) error {
|
||||||
|
if keep < 1 {
|
||||||
|
keep = 1
|
||||||
|
}
|
||||||
|
files, err := client.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(files) <= keep {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for _, file := range files[keep:] {
|
||||||
|
if err := client.Delete(file.Name); err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("删除 %s: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("已清理云端旧备份: %s", file.Name)
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
// ListCloudBackups 列出云端备份
|
// ListCloudBackups 列出云端备份
|
||||||
func ListCloudBackups(c *gin.Context) {
|
func ListCloudBackups(c *gin.Context) {
|
||||||
client, err := getWebDAVClient()
|
client, err := getWebDAVClient()
|
||||||
@@ -217,5 +254,8 @@ func doAutoBackup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.CleanOldBackups(5)
|
config.CleanOldBackups(5)
|
||||||
|
if err := cleanOldRemoteBackups(client, 5); err != nil {
|
||||||
|
log.Printf("云端旧备份清理失败: %v", err)
|
||||||
|
}
|
||||||
log.Printf("自动备份成功: %s", remoteName)
|
log.Printf("自动备份成功: %s", remoteName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
r.GET("/", handlers.IndexHandler)
|
r.GET("/", handlers.IndexHandler)
|
||||||
|
r.GET("/api/language", handlers.GetLanguage)
|
||||||
|
|
||||||
r.GET("/admin/login", func(c *gin.Context) {
|
r.GET("/admin/login", func(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "login.html", nil)
|
c.HTML(http.StatusOK, "login.html", nil)
|
||||||
@@ -72,6 +73,7 @@ func main() {
|
|||||||
|
|
||||||
// MCP 管理(后台 UI)
|
// MCP 管理(后台 UI)
|
||||||
admin.GET("/api/mcp/status", handlers.MCPStatus)
|
admin.GET("/api/mcp/status", handlers.MCPStatus)
|
||||||
|
admin.GET("/api/mcp/token", handlers.MCPGetToken)
|
||||||
admin.POST("/api/mcp/token", handlers.MCPRegenerateToken)
|
admin.POST("/api/mcp/token", handlers.MCPRegenerateToken)
|
||||||
admin.POST("/api/mcp/enabled", handlers.MCPSetEnabled)
|
admin.POST("/api/mcp/enabled", handlers.MCPSetEnabled)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
:root{--bg:#fafafa;--surface:#fff;--surface2:#f5f5f5;--line:#e7e7e8;--text:#18181b;--muted:#71717a;--faint:#a1a1aa;--accent:#2563eb;--green:#16a34a;--red:#dc2626;--radius:8px}*{box-sizing:border-box}html{background:var(--bg);color:var(--text)}html.dark{--bg:#09090b;--surface:#111113;--surface2:#18181b;--line:#27272a;--text:#f4f4f5;--muted:#a1a1aa;--faint:#71717a}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Inter","Segoe UI","PingFang SC",sans-serif;background:var(--bg);color:var(--text)}button,input{font:inherit}.app-layout{display:flex;min-height:100vh}.sidebar{width:224px;border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;flex:none;background:var(--surface)}.brand{font-size:15px;font-weight:650;color:var(--text);padding:6px 10px 24px;text-decoration:none}.section-label{font-size:10px;letter-spacing:.08em;color:var(--faint);padding:0 10px 7px;font-weight:600}.nav-item{width:100%;border:0;background:none;color:var(--muted);display:flex;justify-content:space-between;align-items:center;text-align:left;padding:7px 10px;border-radius:6px;cursor:pointer;margin:1px 0}.nav-item span{font-size:11px;color:var(--faint)}.nav-item:hover{background:var(--surface2);color:var(--text)}.nav-item.active{background:var(--surface2);color:var(--text);font-weight:550}.sidebar-footer{margin-top:auto;padding:12px 10px;border-top:1px solid var(--line)}.sidebar-footer a{color:var(--muted);text-decoration:none;font-size:12px}.main{max-width:1100px;width:100%;margin:0 auto;padding:0 40px}.topbar{height:88px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.eyebrow{font-size:10px;letter-spacing:.1em;color:var(--faint);font-weight:650}.topbar h1{font-size:20px;letter-spacing:-.02em;margin:3px 0 0;font-weight:650}.top-actions{display:flex;align-items:center;gap:10px}.icon-button,.admin-link{border:1px solid var(--line);background:var(--surface);color:var(--muted);border-radius:6px;padding:6px 9px;text-decoration:none;cursor:pointer}.admin-link{font-size:12px}.toolbar{display:flex;align-items:center;gap:14px;padding:20px 0}.search{height:38px;border:1px solid var(--line);background:var(--surface);border-radius:7px;display:flex;align-items:center;gap:9px;padding:0 10px;max-width:620px;width:100%;color:var(--faint)}.search input{border:0;outline:0;background:none;color:var(--text);width:100%}.search kbd{font-size:11px;border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--faint)}.result-count{font-size:12px;color:var(--faint)}.service-list{border-top:1px solid var(--line)}.service-row{min-height:68px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;text-decoration:none;color:inherit;padding:10px 8px;transition:background .12s}.service-row:hover{background:var(--surface2)}.service-icon{width:34px;height:34px;border:1px solid var(--line);border-radius:7px;display:grid;place-items:center;background:var(--surface);font-size:16px;flex:none}.service-main{min-width:0;flex:1;display:flex;flex-direction:column}.service-main strong{font-size:14px;font-weight:570}.service-main span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.service-meta{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;min-width:120px;justify-content:flex-end}.status{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status.online{background:var(--green)}.status.offline{background:var(--red)}.service-arrow{width:24px;color:var(--faint);font-size:16px;text-align:right}.empty-state{padding:70px 10px;text-align:center;color:var(--faint)}.page-footer{display:flex;justify-content:space-between;padding:24px 8px;color:var(--faint);font-size:11px}@media(max-width:760px){.sidebar{width:180px}.main{padding:0 20px}.service-meta{display:none}}@media(max-width:560px){.sidebar{display:none}.main{padding:0 16px}.topbar{height:76px}.admin-link{display:none}.toolbar{padding:14px 0}.service-row{padding:10px 2px}.service-icon{width:32px;height:32px}.service-arrow{display:none}}
|
||||||
|
|
||||||
|
@media(max-width:560px){html,body{width:100%;min-width:0;overflow-x:hidden}.app-layout{min-height:100svh}.main{width:100%;padding:0 12px}.topbar{height:auto;min-height:64px;padding:12px 0;gap:8px}.topbar h1{font-size:18px}.top-actions{flex-shrink:0}.icon-button{min-width:40px;min-height:40px;padding:7px}.toolbar{padding:12px 0;gap:8px}.search{height:42px;min-width:0}.search input{font-size:16px}.search kbd{display:none}.result-count{display:none}.service-row{min-height:64px;gap:9px;padding:9px 1px}.service-icon{width:32px;height:32px;font-size:14px}.service-main strong{font-size:13px}.service-main span{font-size:11px}.service-arrow{display:block;width:18px;font-size:14px}.page-footer{padding:18px 2px}.sidebar{display:none}}
|
||||||
|
@media(max-width:380px){.main{padding:0 10px}.topbar h1{font-size:17px}.service-row{gap:8px}.service-main span{max-width:190px}}
|
||||||
|
|
||||||
|
/* The admin entry must remain visible on mobile */
|
||||||
|
@media(max-width:560px){.top-actions .admin-link{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:7px 11px;font-size:12px;font-weight:550;color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:7px}.top-actions{gap:7px}}
|
||||||
|
|
||||||
|
@media(max-width:560px){.top-actions .admin-link{display:inline-flex!important;align-items:center;justify-content:center;min-height:40px;padding:7px 12px;font-size:13px;font-weight:600;color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:7px}}
|
||||||
|
|
||||||
|
@media(max-width:560px){.top-actions{gap:7px}.top-actions .admin-link{white-space:nowrap}}
|
||||||
|
|
||||||
|
@media(max-width:360px){.topbar h1{font-size:16px}.top-actions .admin-link{padding-left:9px;padding-right:9px;font-size:12px}.icon-button{min-width:38px}}
|
||||||
|
|
||||||
|
@media(max-width:560px){.top-actions .admin-link{display:inline-flex!important}}
|
||||||
|
|
||||||
|
/* cache-bust marker: mobile-admin-visible */
|
||||||
|
|
||||||
|
/* Mobile login CTA: intentionally overrides desktop-hide rule */
|
||||||
|
@media screen and (max-width:560px){.main .topbar .top-actions .admin-link{display:inline-flex!important;visibility:visible!important;opacity:1!important}}
|
||||||
|
.mobile-categories{display:none}
|
||||||
|
@media(max-width:560px){
|
||||||
|
.mobile-categories{display:flex;gap:7px;overflow-x:auto;padding:0 0 12px;margin:0 -2px;scrollbar-width:none}
|
||||||
|
.mobile-categories::-webkit-scrollbar{display:none}
|
||||||
|
.mobile-category{flex:0 0 auto;border:1px solid var(--border);background:var(--surface);color:var(--muted);border-radius:7px;padding:7px 10px;font-size:12px;line-height:1;white-space:nowrap}
|
||||||
|
.mobile-category span{margin-left:4px;opacity:.65}
|
||||||
|
.mobile-category.active{color:var(--text);border-color:var(--text);background:var(--surface-2)}
|
||||||
|
.admin-link{display:inline-flex!important;align-items:center;justify-content:center;min-height:40px;padding:7px 10px;font-size:12px;white-space:nowrap}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="6" y="6" width="52" height="52" rx="13" fill="#18181b"/><path d="M17 18h30v8H37v20h-10V26H17z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 184 B |
@@ -0,0 +1,21 @@
|
|||||||
|
(function(){
|
||||||
|
const zh={
|
||||||
|
"OVERVIEW":"概览","SERVICES":"站点","SYSTEM":"系统","Admin sign in":"管理员登录","Overview":"概览","Service directory and system status":"站点目录与系统状态","Sites":"站点","Services":"站点","Manage site entries":"管理站点条目","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.":"为了安全,继续操作前必须修改初始密码。",
|
||||||
|
"Site directory":"站点目录","Service directory":"站点目录","Search services, tags, descriptions…":"搜索站点、标签、描述…","All":"全部","Uncategorized":"未分类","No services found":"没有找到站点","No sites 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 site directory":"管理站点目录中的条目","Manage entries in your service directory":"管理站点目录中的条目","Search sites...":"搜索站点…","Search services...":"搜索站点…","Service":"站点","Category":"分类","Status":"状态","Order":"排序","Actions":"操作","Add service":"添加站点","Edit service":"编辑站点","+ Add service":"+ 添加站点","+ Add category":"+ 添加分类","Name *":"名称 *","URL *":"网址 *","Icon":"图标","No category":"无分类","Description":"描述","Tags (comma separated)":"标签(逗号分隔)","Sort order":"排序值","Health check URL":"健康检查地址","Enabled":"启用","Health check enabled":"启用健康检查",
|
||||||
|
"Add category":"添加分类","Edit category":"编辑分类","Organize sites into groups":"将站点整理到不同分类","Organize services into groups":"将站点整理到不同分类","Sites - ToNav":"站点 - ToNav","Services - ToNav":"站点 - ToNav","Categories - ToNav":"分类 - ToNav","Site directory":"站点目录","Service directory":"站点目录","Sites":"站点","Site":"站点","Add site":"添加站点","Edit site":"编辑站点","+ Add site":"+ 添加站点","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":"已保存","Settings saved":"设置已保存","Failed":"失败","Backup complete":"备份完成","Done":"完成","Failed":"失败","Copy failed: ":"复制失败:","Delete ":"删除 ","Restore ":"恢复 ","?":"?"
|
||||||
|
};
|
||||||
|
function tr(s){const v=(s||"").trim();if(/^\d+ sites$/.test(v))return v.replace(/^(\d+) sites$/,"$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.tonavLanguage="en";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});
|
||||||
|
})();
|
||||||
@@ -1,176 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Categories - 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">SITES</div><a class="admin-nav" href="/admin/services">Sites</a><a class="admin-nav active" 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>Categories</h1><div class="admin-sub">Organize sites into groups</div></div><div class="top-actions"><a class="btn" href="/admin/services">Sites</a><button class="btn btn-primary" onclick="openModal()">+ Add category</button></div></header><section class="section"><div class="panel" id="list"><div class="empty-state">Loading...</div></div></section></div></main></div>
|
||||||
<html lang="zh-CN">
|
<div id="modal" class="modal-overlay"><div class="modal-content"><div class="modal-head"><h2 id="modalTitle">Add category</h2><button class="close" onclick="closeModal()">×</button></div><input type="hidden" id="editId"><div class="form-group"><label>Name</label><input id="catName" class="input" autofocus></div><div class="form-group"><label>Sort order</label><input id="catOrder" type="number" class="input" value="0"></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="save()">Save</button></div></div></div>
|
||||||
<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: 900px; 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; }
|
|
||||||
.list-card { background: #fff; border-radius: 15px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
|
||||||
.item { padding: 15px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; transition: background .15s; }
|
|
||||||
.item:hover { background: #fafafa; }
|
|
||||||
.item:last-child { border-bottom: none; }
|
|
||||||
.item-info { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.item-order { background: #f0f0f0; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #999; }
|
|
||||||
.item-name { font-weight: 600; font-size: 16px; }
|
|
||||||
.item-actions { display: flex; gap: 8px; }
|
|
||||||
.item-actions button { background: none; border: none; cursor: pointer; padding: 5px 10px; border-radius: 6px; font-size: 13px; transition: background .15s; }
|
|
||||||
.btn-edit { color: var(--primary); }
|
|
||||||
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
|
||||||
.btn-del { color: var(--main-red); }
|
|
||||||
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
|
||||||
.empty { text-align: center; padding: 40px; color: #999; }
|
|
||||||
|
|
||||||
/* 弹窗 */
|
|
||||||
.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: 450px; }
|
|
||||||
.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); }
|
|
||||||
.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; }
|
|
||||||
.item { flex-direction: column; align-items: flex-start; gap: 10px; padding: 12px; }
|
|
||||||
.item-actions { align-self: flex-end; }
|
|
||||||
.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="list-card" id="list">
|
|
||||||
<div class="empty">加载中...</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 style="font-size:13px; color:#666; margin-bottom:4px; display:block">分类名称</label>
|
|
||||||
<input type="text" id="catName" class="input" placeholder="如:内网服务" autofocus>
|
|
||||||
<label style="font-size:13px; color:#666; margin-bottom:4px; display:block">排序(数字越大越靠前)</label>
|
|
||||||
<input type="number" id="catOrder" class="input" placeholder="0" value="0">
|
|
||||||
<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>
|
<script>
|
||||||
async function load() {
|
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('')}
|
||||||
const res = await fetch('/admin/api/categories');
|
function openModal(id,name,order){editId.value=id||'';catName.value=name||'';catOrder.value=order||0;modalTitle.textContent=id?t('Edit category'):t('Add category');modal.style.display='flex';catName.focus()}
|
||||||
const data = await res.json();
|
function edit(c){openModal(c.id,c.name,c.sort_order)}function closeModal(){modal.style.display='none'}
|
||||||
const list = document.getElementById('list');
|
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'))}
|
||||||
if (!data.data || data.data.length === 0) {
|
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'))}
|
||||||
list.innerHTML = '<div class="empty">暂无分类,点击上方"+ 新增分类"添加</div>';
|
function escapeHTML(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal()});load();
|
||||||
return;
|
</script><script src="/static/js/i18n.js?v=3"></script></body></html>
|
||||||
}
|
|
||||||
list.innerHTML = data.data.map(c => `
|
|
||||||
<div class="item">
|
|
||||||
<div class="item-info">
|
|
||||||
<span class="item-order">${c.sort_order}</span>
|
|
||||||
<span class="item-name">${escapeHTML(c.name)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="item-actions">
|
|
||||||
<button class="btn-edit" onclick='edit(${JSON.stringify(c)})'>编辑</button>
|
|
||||||
<button class="btn-del" onclick="del(${c.id}, '${escapeHTML(c.name)}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal(id, name, order) {
|
|
||||||
document.getElementById('editId').value = id || '';
|
|
||||||
document.getElementById('catName').value = name || '';
|
|
||||||
document.getElementById('catOrder').value = order || 0;
|
|
||||||
document.getElementById('modalTitle').textContent = id ? '编辑分类' : '新增分类';
|
|
||||||
document.getElementById('modal').style.display = 'flex';
|
|
||||||
document.getElementById('catName').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
document.getElementById('modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit(c) {
|
|
||||||
openModal(c.id, c.name, c.sort_order);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const id = document.getElementById('editId').value;
|
|
||||||
const name = document.getElementById('catName').value.trim();
|
|
||||||
const order = parseInt(document.getElementById('catOrder').value) || 0;
|
|
||||||
|
|
||||||
if (!name) { alert('分类名称不能为空'); return; }
|
|
||||||
|
|
||||||
const body = { name, sort_order: order };
|
|
||||||
if (id) body.id = parseInt(id);
|
|
||||||
|
|
||||||
const url = id ? `/admin/api/categories/${id}` : '/admin/api/categories';
|
|
||||||
const method = id ? 'PUT' : 'POST';
|
|
||||||
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
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/categories/' + 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC 关闭弹窗
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape') closeModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
load();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,45 +1 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><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?v=3"></script></body></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; }
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
||||||
.card { background: #fff; padding: 40px 30px; border-radius: 20px; width: 100%; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
|
|
||||||
.card h2 { text-align: center; margin-bottom: 20px; font-size: 22px; }
|
|
||||||
.input { width: 100%; padding: 12px; margin: 8px 0 16px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 15px; -webkit-appearance: none; }
|
|
||||||
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
|
||||||
.btn { width: 100%; padding: 13px; background: #ff4d4f; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; margin-top: 10px; font-size: 16px; -webkit-appearance: none; }
|
|
||||||
.btn:active { transform: scale(0.98); }
|
|
||||||
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 16px; text-align: center; font-size: 14px; }
|
|
||||||
.alert { background: #e6f7ff; border: 1px solid #91d5ff; color: #1890ff; padding: 10px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; line-height: 1.5; }
|
|
||||||
label { font-size: 14px; color: #333; font-weight: 500; }
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.card { padding: 30px 20px; border-radius: 16px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h2>🔐 修改密码</h2>
|
|
||||||
<div class="alert">⚠️ 首次登录请先修改初始密码以启用管理功能。</div>
|
|
||||||
|
|
||||||
{{ if .error }}
|
|
||||||
<div class="error-box">{{ .error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<form method="POST">
|
|
||||||
<label>当前密码</label>
|
|
||||||
<input type="password" name="old_password" placeholder="输入当前密码" class="input" required autocomplete="current-password">
|
|
||||||
<label>新密码</label>
|
|
||||||
<input type="password" name="new_password" placeholder="至少6位" class="input" required minlength="6" autocomplete="new-password">
|
|
||||||
<label>确认新密码</label>
|
|
||||||
<input type="password" name="confirm_password" placeholder="再次输入新密码" class="input" required autocomplete="new-password">
|
|
||||||
<button type="submit" class="btn">提交修改</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+52
-315
@@ -1,321 +1,58 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Overview - ToNav</title><link rel="stylesheet" href="/static/css/admin.css">
|
||||||
<title>ToNav 管理后台</title>
|
<script>document.documentElement.classList.toggle('dark',localStorage.getItem('tonav-theme')==='dark');</script>
|
||||||
<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; min-height: 100vh; padding: 20px; }
|
|
||||||
a { text-decoration: none; color: inherit; }
|
|
||||||
.container { max-width: 900px; margin: 0 auto; }
|
|
||||||
.admin-header { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 25px 30px; border-radius: 20px; margin-bottom: 25px; }
|
|
||||||
.header-actions { display: flex; gap: 10px; }
|
|
||||||
.btn { padding: 10px 20px; border-radius: 10px; border: none; cursor: pointer; font-weight: 600; display: inline-block; font-size: 14px; transition: all .2s; }
|
|
||||||
.btn:hover { transform: translateY(-1px); }
|
|
||||||
.btn-light { background: rgba(255,255,255,0.2); color: #fff; }
|
|
||||||
.btn-light:hover { background: rgba(255,255,255,0.3); }
|
|
||||||
.btn-primary { background: var(--main-red); color: #fff; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; }
|
|
||||||
.stat-card { background: #fff; border-radius: 16px; padding: 25px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: transform .2s; }
|
|
||||||
.stat-card:hover { transform: translateY(-3px); }
|
|
||||||
.stat-value { font-size: 36px; font-weight: 700; color: #262626; }
|
|
||||||
.stat-label { font-size: 14px; color: #8c8c8c; margin-top: 5px; }
|
|
||||||
.stat-online .stat-value { color: #52c41a; }
|
|
||||||
.stat-offline .stat-value { color: #ff4d4f; }
|
|
||||||
.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; }
|
|
||||||
.action-btn { background: #fff; border-radius: 16px; padding: 30px 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; transition: all .2s; font-size: 15px; font-weight: 500; border: none; }
|
|
||||||
.action-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.12); }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* 弹窗 */
|
|
||||||
.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: 600px; max-height: 80vh; overflow-y: auto; }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
body { padding: 12px; }
|
|
||||||
.admin-header { flex-direction: column; gap: 15px; text-align: center; padding: 20px; }
|
|
||||||
.admin-header h1 { font-size: 20px; }
|
|
||||||
.header-actions { flex-wrap: wrap; justify-content: center; }
|
|
||||||
.header-actions .btn { padding: 8px 14px; font-size: 13px; }
|
|
||||||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
|
||||||
.stat-card { padding: 18px 10px; }
|
|
||||||
.stat-value { font-size: 28px; }
|
|
||||||
.stat-label { font-size: 12px; }
|
|
||||||
.quick-actions { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
|
||||||
.action-btn { padding: 20px 10px; font-size: 14px; }
|
|
||||||
.modal-content { padding: 20px; border-radius: 16px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="admin-shell">
|
||||||
<div class="admin-header">
|
<aside class="admin-sidebar">
|
||||||
<h1>🧭 ToNav 管理后台</h1>
|
<div class="admin-brand">ToNav</div>
|
||||||
<div class="header-actions">
|
<div class="admin-label">OVERVIEW</div>
|
||||||
<a href="/" class="btn btn-light">查看前台</a>
|
<a class="admin-nav active" href="/admin/dashboard">Overview</a>
|
||||||
<a href="/admin/change-password" class="btn btn-light">修改密码</a>
|
<div class="admin-label" style="margin-top:22px">SITES</div>
|
||||||
<a href="/admin/logout" class="btn btn-primary">退出登录</a>
|
<a class="admin-nav" href="/admin/services">Sites</a>
|
||||||
</div>
|
<a class="admin-nav" href="/admin/categories">Categories</a>
|
||||||
</div>
|
<div class="admin-label" style="margin-top:22px">SYSTEM</div>
|
||||||
|
<a class="admin-nav" href="#settings" onclick="showSettings();return false">Settings</a>
|
||||||
<div class="stats-grid">
|
<div class="admin-sidebar-foot"><a href="/">← Open navigation</a><br><a href="/admin/logout">Sign out</a></div>
|
||||||
<div class="stat-card">
|
</aside>
|
||||||
<div class="stat-value">{{ .service_count }}</div>
|
<main class="admin-content"><div class="admin-main">
|
||||||
<div class="stat-label">总服务数</div>
|
<header class="admin-topbar"><div><h1>Overview</h1><div class="admin-sub">Site directory and system status</div></div><div class="top-actions"><button class="icon-button" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">◐</button><a class="btn" href="/">Open site</a></div></header>
|
||||||
</div>
|
<section class="section"><div class="section-title">System status</div>
|
||||||
<div class="stat-card">
|
<div class="metric-strip"><div class="metric"><div class="metric-value">{{ .service_count }}</div><div class="metric-label">Sites</div></div><div class="metric"><div class="metric-value">{{ .category_count }}</div><div class="metric-label">Categories</div></div><div class="metric"><div class="metric-value green">{{ .online_count }}</div><div class="metric-label">Online</div></div><div class="metric"><div class="metric-value red">{{ .offline_count }}</div><div class="metric-label">Offline</div></div></div></section>
|
||||||
<div class="stat-value">{{ .category_count }}</div>
|
<section class="section"><div class="section-title">Quick actions</div><div class="panel action-list">
|
||||||
<div class="stat-label">分类数</div>
|
<a class="action" href="/admin/services"><strong>Sites</strong><span>Manage site entries</span></a>
|
||||||
</div>
|
<a class="action" href="/admin/categories"><strong>Categories</strong><span>Organize your directory</span></a>
|
||||||
<div class="stat-card stat-online">
|
<a class="action" href="#settings" onclick="showSettings();return false"><strong>Settings</strong><span>Site, backup and MCP</span></a>
|
||||||
<div class="stat-value">{{ .online_count }}</div>
|
<div class="action" onclick="runBackup()"><strong>Backup</strong><span>Run cloud backup now</span></div>
|
||||||
<div class="stat-label">在线服务</div>
|
</div></section>
|
||||||
</div>
|
</div></main></div>
|
||||||
<div class="stat-card stat-offline">
|
<div id="settingsModal" class="modal-overlay"><div class="modal-content">
|
||||||
<div class="stat-value">{{ .offline_count }}</div>
|
<div class="modal-head"><h2>System settings</h2><button class="close" onclick="closeSettings()">×</button></div>
|
||||||
<div class="stat-label">离线服务</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>
|
<div class="form-group"><label>WebDAV URL</label><input id="cfg_url" class="input"></div>
|
||||||
</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>
|
||||||
<div class="quick-actions">
|
<div class="section-title">MCP Server</div><div class="notice"><label><input type="checkbox" id="cfg_mcp_enabled" onchange="toggleMCP()"> Enable MCP endpoint</label><div id="mcpTokenRow" style="display:none;margin-top:12px"><label>Token</label><div class="form-row"><input id="cfg_mcp_token" class="input" readonly><button class="btn" onclick="copyMCPToken(this)">Copy</button><button class="btn" onclick="regenMCPToken()">Regenerate</button></div><div style="margin-top:6px">POST/GET /mcp · changes apply immediately</div></div></div>
|
||||||
<a href="/admin/services" class="action-btn">
|
<div class="section-title" style="margin-top:20px">Cloud backups</div><div class="panel" style="padding:12px"><div id="backupList" class="admin-sub">Loading...</div></div>
|
||||||
<div style="font-size:32px; margin-bottom:10px">📡</div>
|
<div class="modal-actions"><button class="btn" onclick="closeSettings()">Close</button><button class="btn btn-primary" onclick="saveSettings()">Save changes</button></div>
|
||||||
<div>服务管理</div>
|
</div></div>
|
||||||
</a>
|
|
||||||
<a href="/admin/categories" class="action-btn">
|
|
||||||
<div style="font-size:32px; margin-bottom:10px">📂</div>
|
|
||||||
<div>分类管理</div>
|
|
||||||
</a>
|
|
||||||
<div class="action-btn" onclick="runBackup()">
|
|
||||||
<div style="font-size:32px; margin-bottom:10px">☁️</div>
|
|
||||||
<div>立即云备份</div>
|
|
||||||
</div>
|
|
||||||
<div class="action-btn" onclick="showSettings()">
|
|
||||||
<div style="font-size:32px; margin-bottom:10px">⚙️</div>
|
|
||||||
<div>系统设置</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 系统设置弹窗 -->
|
|
||||||
<div id="settingsModal" class="modal-overlay">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h2 style="margin-bottom:20px">⚙️ 系统设置 & 云端备份</h2>
|
|
||||||
|
|
||||||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
|
||||||
<h3 style="font-size:14px; margin-bottom:10px">站点配置</h3>
|
|
||||||
<label style="font-size:12px; color:#666">站点标题</label>
|
|
||||||
<input type="text" id="cfg_site_title" class="input" placeholder="ToNav">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
|
||||||
<h3 style="font-size:14px; margin-bottom:10px">WebDAV 配置</h3>
|
|
||||||
<label style="font-size:12px; color:#666">WebDAV URL</label>
|
|
||||||
<input type="text" id="cfg_url" class="input">
|
|
||||||
<label style="font-size:12px; color:#666">用户名</label>
|
|
||||||
<input type="text" id="cfg_user" class="input">
|
|
||||||
<label style="font-size:12px; color:#666">密码</label>
|
|
||||||
<input type="password" id="cfg_pass" class="input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
|
||||||
<h3 style="font-size:14px; margin-bottom:10px">自动备份</h3>
|
|
||||||
<div style="display:flex; align-items:center; gap:8px">
|
|
||||||
<input type="checkbox" id="cfg_auto_backup" style="width:16px; height:16px">
|
|
||||||
<label for="cfg_auto_backup" style="margin:0; font-size:13px">每天凌晨 3:00 自动备份到云端</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
|
||||||
<h3 style="font-size:14px; margin-bottom:10px">🤖 MCP Server</h3>
|
|
||||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px">
|
|
||||||
<input type="checkbox" id="cfg_mcp_enabled" style="width:16px; height:16px" onchange="toggleMCP()">
|
|
||||||
<label for="cfg_mcp_enabled" style="margin:0; font-size:13px">启用 MCP 接口(AI 可通过它管理导航页)</label>
|
|
||||||
</div>
|
|
||||||
<div id="mcpTokenRow" style="display:none">
|
|
||||||
<label style="font-size:12px; color:#666">Token</label>
|
|
||||||
<div style="display:flex; gap:8px; align-items:center">
|
|
||||||
<input type="text" id="cfg_mcp_token" class="input" readonly style="flex:1; background:#fff; color:#555">
|
|
||||||
<button class="btn btn-primary" onclick="regenMCPToken()" style="padding:8px 14px; white-space:nowrap">🔄 重新生成</button>
|
|
||||||
</div>
|
|
||||||
<div style="font-size:11px; color:#999; margin-top:6px">
|
|
||||||
端点: <code>POST/GET /mcp</code>(Streamable HTTP + SSE)· 改动即时生效,无需重启
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
|
|
||||||
|
|
||||||
<div style="padding:15px; border:1px solid #eee; border-radius:10px">
|
|
||||||
<h3 style="font-size:14px; margin-bottom:10px">📜 云端备份历史</h3>
|
|
||||||
<div id="backupList" style="font-size:13px; color:#666">正在获取列表...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn" onclick="document.getElementById('settingsModal').style.display='none'" style="background:#eee; width:100%; margin-top:15px">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function showSettings() {
|
function toggleTheme(){const dark=!document.documentElement.classList.contains('dark');document.documentElement.classList.toggle('dark',dark);localStorage.setItem('tonav-theme',dark?'dark':'light')}
|
||||||
const resp = await fetch('/admin/api/settings');
|
function closeSettings(){document.getElementById('settingsModal').style.display='none'}
|
||||||
const data = await resp.json();
|
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()}
|
||||||
document.getElementById('cfg_site_title').value = data.site_title || '';
|
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){}}
|
||||||
document.getElementById('cfg_url').value = data.webdav_url || '';
|
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'}}
|
||||||
document.getElementById('cfg_user').value = data.webdav_user || '';
|
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)}}
|
||||||
document.getElementById('cfg_pass').value = data.webdav_password || '';
|
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)}
|
||||||
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
|
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'}}
|
||||||
document.getElementById('settingsModal').style.display = 'flex';
|
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'}
|
||||||
loadMCPStatus();
|
function formatTime(s){if(!s)return'';const d=new Date(s);return isNaN(d)?s:d.toLocaleString('zh-CN')}
|
||||||
loadCloudBackups();
|
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.success?t('Saved'):(d.message||t('Failed')));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 loadMCPStatus() {
|
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)}
|
||||||
try {
|
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeSettings()});
|
||||||
const resp = await fetch('/admin/api/mcp/status');
|
</script><script src="/static/js/i18n.js?v=3"></script></body></html>
|
||||||
const d = await resp.json();
|
|
||||||
const cb = document.getElementById('cfg_mcp_enabled');
|
|
||||||
const row = document.getElementById('mcpTokenRow');
|
|
||||||
cb.checked = d.enabled;
|
|
||||||
row.style.display = d.enabled ? 'block' : 'none';
|
|
||||||
document.getElementById('cfg_mcp_token').value = d.token_masked || '(未生成,点重新生成创建)';
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMCP() {
|
|
||||||
const enabled = document.getElementById('cfg_mcp_enabled').checked;
|
|
||||||
document.getElementById('mcpTokenRow').style.display = enabled ? 'block' : 'none';
|
|
||||||
fetch('/admin/api/mcp/enabled', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({enabled: enabled})
|
|
||||||
}).then(r => r.json()).then(res => {
|
|
||||||
if (!res.success) {
|
|
||||||
alert(res.message);
|
|
||||||
document.getElementById('cfg_mcp_enabled').checked = !enabled;
|
|
||||||
document.getElementById('mcpTokenRow').style.display = enabled ? 'none' : 'block';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function regenMCPToken() {
|
|
||||||
if (!confirm('确定重新生成 Token?\n\n旧 Token 将立即失效,已接入的 AI 客户端需要更新为新 Token。')) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/admin/api/mcp/token', {method: 'POST'});
|
|
||||||
const res = await resp.json();
|
|
||||||
if (res.success) {
|
|
||||||
alert('✅ 新 Token 已生成(仅此一次明文展示,请立即复制):\n\n' + res.token);
|
|
||||||
loadMCPStatus();
|
|
||||||
} else {
|
|
||||||
alert('❌ ' + res.message);
|
|
||||||
}
|
|
||||||
} catch(e) { alert('生成失败'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCloudBackups() {
|
|
||||||
const listDiv = document.getElementById('backupList');
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/admin/api/backup/list');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.files && data.files.length > 0) {
|
|
||||||
listDiv.innerHTML = data.files.map(f => `
|
|
||||||
<div style="padding:10px 0; border-bottom:1px solid #f0f0f0">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:500; color:#333">${f.name}</div>
|
|
||||||
<div style="font-size:11px; color:#999; margin-top:2px">
|
|
||||||
${formatTime(f.mod_time)}${f.size ? ' · ' + formatSize(f.size) : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:6px">
|
|
||||||
<button onclick="restoreBackup('${f.name}')" style="background:none; border:1px solid #1890ff; color:#1890ff; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">恢复</button>
|
|
||||||
<button onclick="deleteBackup('${f.name}')" style="background:none; border:1px solid #ff4d4f; color:#ff4d4f; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
listDiv.innerHTML = '暂无云端备份';
|
|
||||||
}
|
|
||||||
} catch(e) { listDiv.innerHTML = '获取失败'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (!bytes || bytes === 0) return '';
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
try {
|
|
||||||
const d = new Date(str);
|
|
||||||
if (isNaN(d.getTime())) return str;
|
|
||||||
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
|
||||||
} catch(e) { return str; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
const data = {
|
|
||||||
site_title: document.getElementById('cfg_site_title').value,
|
|
||||||
webdav_url: document.getElementById('cfg_url').value,
|
|
||||||
webdav_user: document.getElementById('cfg_user').value,
|
|
||||||
webdav_password: document.getElementById('cfg_pass').value,
|
|
||||||
auto_backup: document.getElementById('cfg_auto_backup').checked ? 'true' : 'false'
|
|
||||||
};
|
|
||||||
const resp = await fetch('/admin/api/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
const res = await resp.json();
|
|
||||||
alert(res.message || '保存成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBackup() {
|
|
||||||
if (!confirm('确定执行云端备份?')) return;
|
|
||||||
const btn = event.currentTarget;
|
|
||||||
const oldContent = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<div style="font-size:32px; margin-bottom:10px">⌛</div><div>正在备份...</div>';
|
|
||||||
btn.style.pointerEvents = 'none';
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/admin/api/backup/webdav', {method: 'POST'});
|
|
||||||
const res = await resp.json();
|
|
||||||
alert(res.message);
|
|
||||||
} catch(e) { alert('备份失败'); }
|
|
||||||
btn.innerHTML = oldContent;
|
|
||||||
btn.style.pointerEvents = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreBackup(name) {
|
|
||||||
if (!confirm(`⚠️ 确定从「${name}」恢复?\n\n当前数据库会先自动备份,然后被覆盖为所选版本。`)) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/admin/api/backup/restore?name=${encodeURIComponent(name)}`, {method: 'POST'});
|
|
||||||
const res = await resp.json();
|
|
||||||
if (res.success) {
|
|
||||||
alert('✅ ' + res.message + '\n\n页面将刷新...');
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert('❌ ' + res.message);
|
|
||||||
}
|
|
||||||
} catch(e) { alert('恢复失败'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteBackup(name) {
|
|
||||||
if (!confirm(`确定删除云端备份「${name}」?此操作不可撤销。`)) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/admin/api/backup/delete?name=${encodeURIComponent(name)}`, {method: 'DELETE'});
|
|
||||||
const res = await resp.json();
|
|
||||||
if (res.success) {
|
|
||||||
loadCloudBackups();
|
|
||||||
} else {
|
|
||||||
alert(res.message);
|
|
||||||
}
|
|
||||||
} catch(e) { alert('删除失败'); }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,40 +1 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><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?v=3"></script></body></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; }
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
||||||
.login-card { background: #fff; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); max-width: 400px; width: 100%; padding: 40px 30px; }
|
|
||||||
.login-card h1 { text-align: center; margin-bottom: 30px; font-size: 28px; }
|
|
||||||
label { display: block; margin-bottom: 6px; font-size: 14px; color: #333; font-weight: 500; }
|
|
||||||
.input { width: 100%; padding: 12px; border: 1px solid #d9d9d9; border-radius: 10px; box-sizing: border-box; margin-bottom: 18px; font-size: 15px; -webkit-appearance: none; }
|
|
||||||
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
|
||||||
.btn { width: 100%; padding: 13px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; background: var(--main-red); color: #fff; font-size: 16px; -webkit-appearance: none; }
|
|
||||||
.btn:active { transform: scale(0.98); }
|
|
||||||
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-size: 14px; }
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.login-card { padding: 30px 20px; border-radius: 16px; }
|
|
||||||
.login-card h1 { font-size: 24px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-card">
|
|
||||||
<h1>🧭 ToNav</h1>
|
|
||||||
{{ if .error }}
|
|
||||||
<div class="error-box">{{ .error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
<form method="POST">
|
|
||||||
<label>用户名</label>
|
|
||||||
<input type="text" name="username" class="input" required autofocus autocomplete="username">
|
|
||||||
<label>密码</label>
|
|
||||||
<input type="password" name="password" class="input" required autocomplete="current-password">
|
|
||||||
<button type="submit" class="btn">登 录</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,289 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sites - 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">SITES</div><a class="admin-nav active" href="/admin/services">Sites</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>Sites</h1><div class="admin-sub">Manage entries in your site directory</div></div><div class="top-actions"><a class="btn" href="/">Open site</a><button class="btn btn-primary" onclick="openModal()">+ Add site</button></div></header><section class="section"><div class="table-toolbar"><input id="filter" class="search-input" placeholder="Search sites..." oninput="load()"></div><div class="panel table-wrap"><table class="data-table"><thead><tr><th>Site</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 sites found.</div></div></section></div></main></div>
|
||||||
<html lang="zh-CN">
|
<div id="modal" class="modal-overlay"><div class="modal-content"><div class="modal-head"><h2 id="modalTitle">Add site</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>
|
||||||
<head>
|
<script>const categoryMap={};{{ range .categories }}categoryMap[{{ .ID }}]={{ printf "%q" .Name }};{{ end }}
|
||||||
<meta charset="UTF-8">
|
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('')}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
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=t('Add site');modal.style.display='flex';svcName.focus()}
|
||||||
<title>服务管理 - ToNav</title>
|
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=t('Edit site');modal.style.display='flex'}function closeModal(){modal.style.display='none'}
|
||||||
<style>
|
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'))}
|
||||||
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
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'))}
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
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?v=3"></script></body></html>
|
||||||
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>
|
|
||||||
|
|||||||
+12
-307
@@ -1,308 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN"><head><meta charset="UTF-8"><link rel="icon" href="/static/favicon.svg?v=2" type="image/svg+xml"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ .site_title }}</title><link rel="stylesheet" href="/static/css/tonav.css?v=mobile2"></head>
|
||||||
<head>
|
<body class="app-shell">
|
||||||
<meta charset="UTF-8">
|
<div class="app-layout">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<aside class="sidebar"><a class="brand" href="/">{{ .site_title }}</a><div class="sidebar-section"><div class="section-label">SITES</div><button class="nav-item active" data-category="all">All <span id="allCount"></span></button><div id="categoryNav"></div></div><div class="sidebar-footer"><a href="/admin/login">Admin sign in <span>↗</span></a></div></aside>
|
||||||
<title>{{ .site_title }} - 导航服务</title>
|
<main class="main"><header class="topbar"><div><div class="eyebrow">SITES</div><h1>Site directory</h1></div><div class="top-actions"><a class="admin-link" href="/admin/login">Sign in</a><button class="icon-button" id="themeBtn" title="Toggle theme" aria-label="Toggle theme">◐</button></div></header>
|
||||||
<style>
|
<div class="mobile-categories" id="mobileCategories"></div><div class="toolbar"><label class="search"><span>⌕</span><input id="searchInput" placeholder="Search sites, tags, descriptions…" autocomplete="off"><kbd>/</kbd></label><div class="result-count" id="resultCount"></div></div>
|
||||||
:root {
|
<section class="service-list" id="sitesGrid"><div class="empty-state">Loading…</div></section>
|
||||||
--main-red: #ff4d4f;
|
<footer class="page-footer"><span>ToNav</span><span id="lastCheckTime"></span></footer></main></div>
|
||||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
<script>const categoriesData={{ .categories_json }};const sitesData={{ .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};
|
||||||
--header-gradient: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
function renderNav(){const n=document.getElementById('categoryNav');const m=document.getElementById('mobileCategories');document.getElementById('allCount').textContent=sitesData.length;const items=categoriesData.map(c=>`<button class="nav-item" data-category="${esc(c.name)}">${esc(c.name)} <span>${sitesData.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>${sitesData.length}</span></button>`+categoriesData.map(c=>`<button class="mobile-category" data-category="${esc(c.name)}">${esc(c.name)} <span>${sitesData.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'?sitesData:sitesData.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} sites`;document.getElementById('sitesGrid').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 sites found</div>'}
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
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?v=3"></script></body></html>
|
||||||
body {
|
|
||||||
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
a { text-decoration: none; color: inherit; }
|
|
||||||
.container { max-width: 800px; margin: 0 auto; }
|
|
||||||
|
|
||||||
/* 头部 */
|
|
||||||
.header {
|
|
||||||
background: var(--header-gradient);
|
|
||||||
color: #fff;
|
|
||||||
padding: 25px 20px;
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.header h1 { font-size: 24px; font-weight: 700; margin-bottom: 5px; }
|
|
||||||
.subtitle { font-size: 13px; color: #8c8c8c; margin-bottom: 10px; }
|
|
||||||
.status-bar { font-size: 12px; color: #595959; }
|
|
||||||
|
|
||||||
/* 搜索栏 */
|
|
||||||
.search-bar {
|
|
||||||
background: #262626;
|
|
||||||
padding: 15px 20px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.search-bar input {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
border: 1px solid rgba(255,255,255,0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 15px 10px 40px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
.search-bar input:focus {
|
|
||||||
outline: none;
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
border-color: var(--main-red);
|
|
||||||
}
|
|
||||||
.search-bar input::placeholder { color: #8c8c8c; }
|
|
||||||
.search-icon { position: absolute; left: 35px; color: #8c8c8c; }
|
|
||||||
|
|
||||||
/* 分类 Tabs */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
background: #262626;
|
|
||||||
padding: 8px;
|
|
||||||
gap: 5px;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.tabs::-webkit-scrollbar { display: none; }
|
|
||||||
.tab-btn {
|
|
||||||
flex: 1;
|
|
||||||
min-width: fit-content;
|
|
||||||
padding: 12px 10px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.tab-btn:hover { color: #bfbfbf; background: rgba(255,255,255,0.05); }
|
|
||||||
.tab-btn.active {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--main-red);
|
|
||||||
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 服务卡片网格 */
|
|
||||||
.services-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
padding: 20px 0;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
.service-card {
|
|
||||||
background: #f7f7f8;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
animation: fadeInUp 0.5s ease backwards;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.service-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
|
||||||
border-color: rgba(102,126,234,0.3);
|
|
||||||
}
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.card-icon { font-size: 30px; margin-right: 14px; flex-shrink: 0; }
|
|
||||||
.card-info { min-width: 0; flex: 1; }
|
|
||||||
.card-name {
|
|
||||||
font-size: 16px; font-weight: 600; color: #262626;
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.card-desc {
|
|
||||||
font-size: 13px; color: #8c8c8c; margin-top: 4px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
|
||||||
display: inline-block; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-dot.online {
|
|
||||||
background: #52c41a;
|
|
||||||
box-shadow: 0 0 6px rgba(82,196,26,0.4);
|
|
||||||
}
|
|
||||||
.status-dot.offline {
|
|
||||||
background: #f5222d;
|
|
||||||
box-shadow: 0 0 6px rgba(245,34,45,0.4);
|
|
||||||
}
|
|
||||||
.status-dot.unknown {
|
|
||||||
background: #d9d9d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-tags { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
||||||
.card-tag {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #667eea;
|
|
||||||
background: rgba(102,126,234,0.1);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部 */
|
|
||||||
.footer {
|
|
||||||
background: var(--header-gradient);
|
|
||||||
color: #8c8c8c;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 0 0 20px 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.footer a { color: var(--main-red); transition: opacity 0.3s; }
|
|
||||||
.footer a:hover { opacity: 0.8; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
body { padding: 10px; }
|
|
||||||
.services-grid { grid-template-columns: 1fr; }
|
|
||||||
.tab-btn { font-size: 13px; padding: 10px 8px; }
|
|
||||||
.header { padding: 20px 15px; border-radius: 16px 16px 0 0; }
|
|
||||||
.footer { border-radius: 0 0 16px 16px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🧭 {{ .site_title }}</h1>
|
|
||||||
<div class="subtitle">个人导航站</div>
|
|
||||||
<div class="status-bar"><span id="lastCheckTime">加载中...</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text" id="searchInput" placeholder="搜索服务或描述..." oninput="handleSearch()">
|
|
||||||
<span class="search-icon">🔍</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tabs" id="categoryTabs"></div>
|
|
||||||
|
|
||||||
<div class="services-grid" id="servicesGrid">
|
|
||||||
<div class="empty-state">加载中...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>© 2026 ToNav - <a href="/admin">管理后台</a></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 服务端渲染的数据
|
|
||||||
const categoriesData = {{ .categories_json }};
|
|
||||||
const servicesData = {{ .services_json }};
|
|
||||||
|
|
||||||
// 构建分类ID → 名称映射
|
|
||||||
const categoryMap = {};
|
|
||||||
categoriesData.forEach(c => { categoryMap[c.id] = c.name; });
|
|
||||||
|
|
||||||
let currentTab = 'all';
|
|
||||||
let currentKeyword = '';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
renderTabs();
|
|
||||||
renderServices('all');
|
|
||||||
updateLastCheckTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderTabs() {
|
|
||||||
const container = document.getElementById('categoryTabs');
|
|
||||||
let html = '<button class="tab-btn active" data-category="all">全部</button>';
|
|
||||||
categoriesData.forEach(c => {
|
|
||||||
html += `<button class="tab-btn" data-category="${c.name}">${c.name}</button>`;
|
|
||||||
});
|
|
||||||
container.innerHTML = html;
|
|
||||||
|
|
||||||
container.querySelectorAll('.tab-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
currentTab = this.dataset.category;
|
|
||||||
container.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
this.classList.add('active');
|
|
||||||
renderServices(currentTab);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
currentKeyword = document.getElementById('searchInput').value.toLowerCase();
|
|
||||||
renderServices(currentTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderServices(category) {
|
|
||||||
const container = document.getElementById('servicesGrid');
|
|
||||||
|
|
||||||
let filtered = category === 'all'
|
|
||||||
? servicesData
|
|
||||||
: servicesData.filter(s => categoryMap[s.category_id] === category);
|
|
||||||
|
|
||||||
if (currentKeyword) {
|
|
||||||
filtered = filtered.filter(s =>
|
|
||||||
s.name.toLowerCase().includes(currentKeyword) ||
|
|
||||||
(s.description && s.description.toLowerCase().includes(currentKeyword)) ||
|
|
||||||
(s.tags && s.tags.toLowerCase().includes(currentKeyword))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
container.innerHTML = '<div class="empty-state">暂无服务</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = filtered.map((s, i) => {
|
|
||||||
const statusClass = s.status === 'online' ? 'online' : (s.status === 'offline' ? 'offline' : 'unknown');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<a href="${escapeAttr(s.url)}" target="_blank" class="service-card" style="animation-delay: ${i * 0.05}s">
|
|
||||||
<div class="card-icon">${s.icon || '🔗'}</div>
|
|
||||||
<div class="card-info">
|
|
||||||
<div class="card-name">${escapeHTML(s.name)} <span class="status-dot ${statusClass}"></span></div>
|
|
||||||
${s.description ? `<div class="card-desc">${escapeHTML(s.description)}</div>` : ''}
|
|
||||||
${s.tags ? `<div class="card-tags">${s.tags.split(',').map(t => `<span class="card-tag">${escapeHTML(t.trim())}</span>`).join('')}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHTML(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str || '';
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
function escapeAttr(str) {
|
|
||||||
return (str||'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
function updateLastCheckTime() {
|
|
||||||
const now = new Date();
|
|
||||||
document.getElementById('lastCheckTime').textContent = '最后更新: ' +
|
|
||||||
now.toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(() => { location.reload(); }, 30000);
|
|
||||||
document.addEventListener('visibilitychange', function() {
|
|
||||||
if (!document.hidden) location.reload();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user