diff --git a/api.exe b/api.exe deleted file mode 100644 index 6c55a64..0000000 Binary files a/api.exe and /dev/null differ diff --git a/cmd/api/main_entry.go b/cmd/api/main_entry.go index 0c1e7b0..7397218 100644 --- a/cmd/api/main_entry.go +++ b/cmd/api/main_entry.go @@ -38,7 +38,6 @@ func registerV1(v1 *gin.RouterGroup) { registerUserRoutes(v1) registerClientRoutes(v1) registerServerRoutesV1(v1) - registerPluginRoutesV1(v1) } func registerV2(v2 *gin.RouterGroup) { @@ -114,6 +113,14 @@ func registerUserRoutes(v1 *gin.RouterGroup) { user.POST("/ticket/save", handler.UserTicketSave) user.GET("/ticket/fetch", handler.UserTicketFetch) user.POST("/ticket/withdraw", handler.UserTicketWithdraw) + + // Integrated User Features + user.GET("/real-name-verification/status", handler.PluginRealNameStatus) + user.POST("/real-name-verification/submit", handler.PluginRealNameSubmit) + user.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP) + user.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable) + user.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword) + user.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check) } func registerUserRoutesV2(v2 *gin.RouterGroup) { @@ -175,35 +182,13 @@ func registerServerRoutesV2(v2 *gin.RouterGroup) { server.POST("/status", handler.NodeStatus) } -func registerPluginRoutesV1(v1 *gin.RouterGroup) { - securePath := service.GetAdminSecurePath() - - adminPlugins := v1.Group("/" + securePath) - adminPlugins.Use(middleware.Auth(), middleware.AdminAuth()) - adminPlugins.GET("/realname/records", handler.PluginRealNameRecords) - adminPlugins.POST("/realname/clear-cache", handler.PluginRealNameClearCache) - adminPlugins.POST("/realname/review/:userId", handler.PluginRealNameReview) - adminPlugins.POST("/realname/reset/:userId", handler.PluginRealNameReset) - adminPlugins.POST("/realname/sync-all", handler.PluginRealNameSyncAll) - adminPlugins.POST("/realname/approve-all", handler.PluginRealNameApproveAll) - adminPlugins.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers) - - userPlugins := v1.Group("/user") - userPlugins.Use(middleware.Auth()) - userPlugins.GET("/real-name-verification/status", handler.PluginRealNameStatus) - userPlugins.POST("/real-name-verification/submit", handler.PluginRealNameSubmit) - userPlugins.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP) - userPlugins.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable) - userPlugins.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword) - userPlugins.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check) -} - func registerAdminRoutesV2(v2 *gin.RouterGroup) { admin := v2.Group("/" + service.GetAdminSecurePath()) admin.Use(middleware.Auth(), middleware.AdminAuth()) admin.GET("/config/fetch", handler.AdminConfigFetch) admin.POST("/config/save", handler.AdminConfigSave) + admin.GET("/dashboard/summary", handler.AdminDashboardSummary) admin.GET("/server/group/fetch", handler.AdminServerGroupsFetch) admin.POST("/server/group/save", handler.AdminServerGroupSave) admin.POST("/server/group/drop", handler.AdminServerGroupDrop) @@ -220,9 +205,35 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) { admin.POST("/server/manage/resetTraffic", handler.AdminServerManageResetTraffic) admin.POST("/server/manage/batchResetTraffic", handler.AdminServerManageBatchResetTraffic) admin.GET("/system/getSystemStatus", handler.AdminSystemStatus) - admin.GET("/plugin/getPlugins", handler.AdminPluginsList) - admin.GET("/plugin/types", handler.AdminPluginTypes) - admin.GET("/plugin/integration-status", handler.AdminPluginIntegrationStatus) + admin.GET("/plan/fetch", handler.AdminPlansFetch) + admin.POST("/plan/save", handler.AdminPlanSave) + admin.POST("/plan/drop", handler.AdminPlanDrop) + admin.POST("/plan/sort", handler.AdminPlanSort) + + admin.GET("/order/fetch", handler.AdminOrdersFetch) + admin.POST("/order/paid", handler.AdminOrderPaid) + admin.POST("/order/cancel", handler.AdminOrderCancel) + + admin.GET("/coupon/fetch", handler.AdminCouponsFetch) + admin.POST("/coupon/save", handler.AdminCouponSave) + admin.POST("/coupon/drop", handler.AdminCouponDrop) + + admin.GET("/user/fetch", handler.AdminUsersFetch) + admin.POST("/user/update", handler.AdminUserUpdate) + admin.POST("/user/ban", handler.AdminUserBan) + admin.POST("/user/resetTraffic", handler.AdminUserResetTraffic) + admin.POST("/user/drop", handler.AdminUserDelete) + admin.GET("/ticket/fetch", handler.AdminTicketsFetch) + admin.GET("/gift-card/status", handler.AdminGiftCardStatus) + + // Integrated Admin Features + admin.GET("/realname/records", handler.PluginRealNameRecords) + admin.POST("/realname/clear-cache", handler.PluginRealNameClearCache) + admin.POST("/realname/review/:userId", handler.PluginRealNameReview) + admin.POST("/realname/reset/:userId", handler.PluginRealNameReset) + admin.POST("/realname/sync-all", handler.PluginRealNameSyncAll) + admin.POST("/realname/approve-all", handler.PluginRealNameApproveAll) + admin.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers) } func registerWebRoutes(router *gin.Engine) { diff --git a/frontend/admin/app.css b/frontend/admin/app.css index e50ebe9..ff6f1e4 100644 --- a/frontend/admin/app.css +++ b/frontend/admin/app.css @@ -1,677 +1,487 @@ :root { - --bg: #eff3f9; - --bg-accent: #f8fbff; - --surface: #ffffff; - --surface-soft: #f5f8fc; - --surface-strong: #ecf2fb; - --border: #d9e1ef; - --border-strong: #c8d4e7; - --text: #172033; - --muted: #61708b; - --muted-soft: #8c9ab2; - --brand: #1677ff; - --brand-strong: #0f5fd6; - --brand-soft: #eaf2ff; - --success: #168a54; - --warn: #d97706; - --danger: #c0392b; - --sidebar: #0f1728; - --sidebar-panel: #141f35; - --sidebar-muted: #8ea0c3; + /* Nebula Dark Palette */ + --bg: #07101b; + --panel: rgba(9, 20, 35, 0.78); + --panel-strong: rgba(11, 24, 41, 0.92); + --text: #eef6ff; + --text-dim: rgba(227, 239, 255, 0.72); + --text-faint: rgba(227, 239, 255, 0.54); + --border: rgba(152, 192, 255, 0.16); + --shadow: 0 24px 80px rgba(0, 0, 0, 0.34); + --shadow-soft: 0 12px 32px rgba(0, 0, 0, 0.22); + + /* Brand/Accent */ + --accent: #6ce5ff; + --accent-strong: #21b8ff; + --accent-soft: rgba(108, 229, 255, 0.12); + --accent-rgb: 108, 229, 255; + + /* Indicators */ + --success: #65f0b7; + --warn: #ffca7a; + --danger: #ff8db5; + + /* Radius */ + --radius-xl: 28px; + --radius-lg: 20px; + --radius-md: 14px; + + /* Sidebar */ + --sidebar: #091221; --sidebar-active: #ffffff; - --sidebar-border: rgba(255, 255, 255, 0.08); - --shadow: 0 20px 48px rgba(15, 23, 42, 0.08); - --shadow-soft: 0 12px 28px rgba(15, 23, 42, 0.05); - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --sidebar-muted: rgba(227, 239, 255, 0.5); + + font-family: "Avenir Next", "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; } * { box-sizing: border-box; } -html, -body { +html, body { + margin: 0; min-height: 100%; + background: + radial-gradient(circle at top left, rgba(var(--accent-rgb), 0.12), transparent 30%), + radial-gradient(circle at top right, rgba(255, 201, 122, 0.08), transparent 22%), + linear-gradient(180deg, #050d16 0%, #07101b 100%); + color: var(--text); + overflow-x: hidden; } -body { - margin: 0; - color: var(--text); - background: - radial-gradient(circle at top left, rgba(22, 119, 255, 0.12), transparent 28%), - radial-gradient(circle at right top, rgba(15, 95, 214, 0.08), transparent 24%), - linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 100%); +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(155, 192, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(155, 192, 255, 0.04) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: radial-gradient(circle at top center, black, transparent 80%); + pointer-events: none; + z-index: -1; } a { color: inherit; text-decoration: none; + transition: opacity 0.2s ease; } -button, -input, -select, -textarea { +a:hover { + opacity: 0.8; +} + +button, input, select, textarea { font: inherit; + background: transparent; + color: inherit; } +/* Glass Component Base */ +.glass-card { + background: var(--panel); + backdrop-filter: blur(18px); + border: 1px solid var(--border); + box-shadow: var(--shadow-soft); +} + +/* Main Layout */ .admin-shell { min-height: 100vh; display: grid; - grid-template-columns: 272px minmax(0, 1fr); + grid-template-columns: 280px minmax(0, 1fr); + gap: 0; } +/* Sidebar */ .admin-sidebar { position: sticky; top: 0; height: 100vh; - overflow: auto; - padding: 26px 18px 22px; - color: #fff; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)), - linear-gradient(180deg, #121c2f 0%, #0d1524 100%); - border-right: 1px solid rgba(255, 255, 255, 0.03); + background: var(--sidebar); + border-right: 1px solid var(--border); + padding: 32px 20px; + display: flex; + flex-direction: column; + gap: 32px; } .sidebar-brand { - padding: 18px 16px 20px; - border-radius: 20px; - border: 1px solid var(--sidebar-border); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + padding: 0 12px; } .sidebar-brand-mark { display: inline-flex; - align-items: center; - margin-bottom: 12px; - padding: 6px 10px; + padding: 6px 12px; + background: var(--accent-soft); + color: var(--accent); + border: 1px solid rgba(var(--accent-rgb), 0.2); border-radius: 999px; - background: rgba(22, 119, 255, 0.16); - color: #b9d5ff; font-size: 11px; - letter-spacing: 0.08em; + font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; + margin-bottom: 14px; } .sidebar-brand strong { display: block; - font-size: 24px; - line-height: 1.15; - color: var(--sidebar-active); + font-size: 26px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; } -.sidebar-brand span:last-child { +.sidebar-brand span { display: block; - margin-top: 10px; - color: var(--sidebar-muted); - font-size: 12px; - line-height: 1.5; -} - -.sidebar-nav-label, -.sidebar-meta-label { - display: block; - color: var(--sidebar-muted); - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; + font-size: 13px; + color: var(--text-faint); + margin-top: 8px; } .sidebar-nav-label { - margin: 22px 12px 10px; + display: block; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-faint); + margin: 24px 12px 12px; } .sidebar-nav { display: grid; - gap: 8px; + gap: 6px; } .sidebar-item { - position: relative; display: block; + padding: 12px 16px; + border-radius: var(--radius-md); + color: var(--sidebar-muted); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); border: 1px solid transparent; - border-radius: 16px; - padding: 14px 14px 14px 16px; - transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; } .sidebar-item:hover { - transform: translateX(2px); - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + transform: translateX(4px); } .sidebar-item.active { - background: linear-gradient(180deg, rgba(22, 119, 255, 0.22), rgba(22, 119, 255, 0.12)); - border-color: rgba(95, 162, 255, 0.28); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); -} - -.sidebar-item.active::before { - content: ""; - position: absolute; - left: -6px; - top: 14px; - bottom: 14px; - width: 3px; - border-radius: 999px; - background: #7db3ff; + background: var(--accent-soft); + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.24); + box-shadow: inset 0 0 12px rgba(var(--accent-rgb), 0.08); } .sidebar-item strong { display: block; - font-size: 14px; - color: var(--sidebar-active); + font-size: 15px; + font-weight: 700; } .sidebar-item span { display: block; - margin-top: 6px; - color: var(--sidebar-muted); font-size: 12px; - line-height: 1.45; -} - -.sidebar-meta, -.sidebar-footer { - margin-top: 18px; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid var(--sidebar-border); - background: rgba(255, 255, 255, 0.04); -} - -.sidebar-meta strong { - display: block; - margin-top: 8px; - color: var(--sidebar-active); - font-size: 15px; -} - -.sidebar-footer { - color: var(--sidebar-muted); - font-size: 12px; - line-height: 1.65; + opacity: 0.7; + margin-top: 2px; } +/* Main Content */ .admin-main { + padding: 32px 40px; min-width: 0; - padding: 22px 26px 30px; } .topbar { - position: sticky; - top: 0; - z-index: 5; + padding: 24px 28px; + border-radius: var(--radius-xl); + margin-bottom: 32px; display: flex; - align-items: flex-start; justify-content: space-between; - gap: 20px; - padding: 18px 22px; - margin-bottom: 22px; - border: 1px solid rgba(255, 255, 255, 0.72); - border-radius: 24px; - background: rgba(255, 255, 255, 0.84); - backdrop-filter: blur(18px); - box-shadow: var(--shadow-soft); + align-items: flex-start; + gap: 24px; } -.topbar-copy { - min-width: 0; +.topbar-copy h1 { + margin: 0; + font-size: 32px; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; +} + +.topbar-copy p { + margin: 10px 0 0; + font-size: 15px; + color: var(--text-dim); } .topbar-eyebrow { display: inline-flex; - margin-bottom: 8px; - padding: 5px 10px; - border-radius: 999px; - background: var(--brand-soft); - color: var(--brand-strong); font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; text-transform: uppercase; -} - -.topbar h1 { - margin: 0; - font-size: 30px; - line-height: 1.15; -} - -.topbar p { - margin: 8px 0 0; - color: var(--muted); - line-height: 1.6; + letter-spacing: 0.15em; + color: var(--accent); + margin-bottom: 10px; } .topbar-meta { display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 14px; + gap: 12px; + margin-top: 16px; } .topbar-chip { - display: inline-flex; - align-items: center; - padding: 7px 11px; + padding: 6px 12px; border-radius: 999px; + background: rgba(255,255,255,0.05); border: 1px solid var(--border); - background: var(--surface-soft); - color: var(--muted); font-size: 12px; + color: var(--text-dim); } -.page-shell { - display: grid; - gap: 18px; -} - -.page-hero, -.page-section, -.plugin-card, -.stat-card { - overflow: hidden; -} - -.page-hero { - display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(240px, 0.8fr); - gap: 20px; - align-items: stretch; -} - -.page-hero-copy h2 { - margin: 10px 0 10px; - font-size: 28px; - line-height: 1.2; -} - -.page-hero-copy p { - margin: 0; - color: var(--muted); - line-height: 1.7; -} - -.page-hero-label, -.section-kicker { - display: inline-flex; - color: var(--brand-strong); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.page-hero-side { - display: grid; - gap: 12px; -} - -.hero-metric { - display: grid; - align-content: center; - gap: 6px; - padding: 18px; - border: 1px solid var(--border); - border-radius: 18px; - background: linear-gradient(180deg, var(--surface-soft), #fff); -} - -.hero-metric span { - color: var(--muted); - font-size: 12px; -} - -.hero-metric strong { - font-size: 22px; - line-height: 1.2; -} - +/* Cards & Grid */ .grid { display: grid; - gap: 18px; + gap: 20px; } .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.plugin-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - .card { - background: var(--surface); - border: 1px solid rgba(255, 255, 255, 0.72); - border-radius: 24px; - box-shadow: var(--shadow); - padding: 22px; + padding: 28px; + border-radius: var(--radius-xl); } -.card h2, -.card h3 { - margin: 0; -} - -.card p { - margin: 0; - color: var(--muted); - line-height: 1.65; -} - -.section-headline { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - margin-bottom: 18px; -} - -.section-headline h2 { - margin-top: 8px; +.card h2 { font-size: 22px; -} - -.section-copy { - max-width: 520px; - color: var(--muted); - line-height: 1.65; -} - -.status-strip { - display: flex; - align-items: center; -} - -.stat { - display: grid; - gap: 10px; -} - -.stat strong { - font-size: 30px; - line-height: 1.15; -} - -.hint { - color: var(--muted); - font-size: 13px; - line-height: 1.6; -} - -.toolbar { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; - border: 1px solid transparent; - border-radius: 14px; - padding: 10px 16px; - cursor: pointer; - font-weight: 600; - transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn-primary { - color: #fff; - background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%); - box-shadow: 0 10px 20px rgba(22, 119, 255, 0.2); -} - -.btn-secondary { - color: var(--brand-strong); - background: var(--brand-soft); - border-color: rgba(22, 119, 255, 0.08); -} - -.btn-ghost { - color: var(--text); - background: #fff; - border-color: var(--border); -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - width: fit-content; - padding: 7px 12px; - border-radius: 999px; - font-size: 12px; font-weight: 700; + margin: 0 0 14px; } -.status-ok { - background: rgba(22, 138, 84, 0.12); - color: var(--success); +.stat-card { + display: flex; + flex-direction: column; + gap: 12px; } -.status-warn { - background: rgba(217, 119, 6, 0.12); - color: var(--warn); +.stat-card strong { + font-size: 34px; + font-weight: 800; + letter-spacing: -0.04em; + color: var(--accent); } -.status-danger { - background: rgba(192, 57, 43, 0.12); - color: var(--danger); +.stat-card span { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); } +/* Table Design */ .table-wrap { - overflow: auto; + border-radius: var(--radius-lg); border: 1px solid var(--border); - border-radius: 18px; - background: #fff; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; } table { width: 100%; border-collapse: collapse; - min-width: 680px; } -th, -td { +th, td { + padding: 16px 20px; text-align: left; - padding: 14px 14px; border-bottom: 1px solid var(--border); - vertical-align: top; } th { - position: sticky; - top: 0; - z-index: 1; - background: var(--surface-soft); - color: var(--muted); + background: rgba(255, 255, 255, 0.04); font-size: 12px; font-weight: 700; - letter-spacing: 0.02em; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-faint); } tbody tr:hover { - background: #f8fbff; -} - -td { - font-size: 14px; + background: rgba(255, 255, 255, 0.02); } tbody tr:last-child td { border-bottom: 0; } -.row-actions { - display: flex; +/* Forms & Inputs */ +.field { + display: grid; gap: 8px; - flex-wrap: wrap; + margin-bottom: 18px; } -.login-shell { - min-height: 100vh; +.field label { + font-size: 13px; + font-weight: 600; + color: var(--text-dim); +} + +.field input, .field select, .field textarea { + width: 100%; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: var(--radius-md); + transition: all 0.2s ease; +} + +.field input:focus, .field select:focus { + border-color: var(--accent); + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.1); + outline: none; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 24px; + border-radius: var(--radius-md); + font-weight: 700; + font-size: 14px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + border: 1px solid transparent; +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #050d16; + box-shadow: 0 12px 32px rgba(var(--accent-rgb), 0.24); +} + +.btn-secondary { + background: rgba(255,255,255,0.05); + border-color: var(--border); + color: var(--text); +} + +.btn-ghost { + background: transparent; + color: var(--accent); + padding: 8px 12px; +} + +.btn-ghost:hover { + background: var(--accent-soft); +} + +/* Status Badges */ +.status-pill { + padding: 6px 12px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + display: inline-flex; +} + +.status-ok { background: rgba(101, 240, 183, 0.12); color: var(--success); border: 1px solid rgba(101, 240, 183, 0.2); } +.status-warn { background: rgba(255, 202, 122, 0.12); color: var(--warn); border: 1px solid rgba(255, 202, 122, 0.2); } +.status-danger { background: rgba(255, 141, 181, 0.12); color: var(--danger); border: 1px solid rgba(255, 141, 181, 0.2); } + +/* Progress */ +.progress-bar { + height: 8px; + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + box-shadow: 0 0 12px var(--accent-soft); +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 100; display: grid; place-items: center; padding: 24px; } -.login-card { - width: min(480px, 100%); - background: rgba(255, 255, 255, 0.9); - border: 1px solid rgba(255, 255, 255, 0.76); - border-radius: 28px; - padding: 30px; - box-shadow: var(--shadow); - backdrop-filter: blur(12px); +.modal-card { + width: min(100%, 640px); + animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } -.login-card form, -.filter-form { - display: grid; +@keyframes modalIn { + from { opacity: 0; transform: scale(0.9) translateY(20px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +/* Pagination */ +.pagination-shell { + display: flex; + align-items: center; gap: 12px; + margin-top: 24px; } -.field { +.page-btn { + width: 40px; + height: 40px; display: grid; - gap: 8px; -} - -.field label { - color: var(--muted); - font-size: 13px; -} - -.field input, -.field select, -.field textarea { - width: 100%; - min-height: 44px; - padding: 12px 14px; - border: 1px solid var(--border); - border-radius: 14px; - background: #fff; - color: var(--text); - outline: none; -} - -.field input:focus, -.field select:focus, -.field textarea:focus { - border-color: rgba(22, 119, 255, 0.4); - box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.08); -} - -.notice { - padding: 12px 14px; - border-radius: 14px; - font-size: 13px; - line-height: 1.5; -} - -.notice.error { - background: rgba(192, 57, 43, 0.12); - color: var(--danger); -} - -.notice.success { - background: rgba(22, 138, 84, 0.12); - color: var(--success); -} - -.stack { - display: grid; - gap: 18px; -} - -pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - border-radius: 16px; - background: #0f172a; - color: #dbe7ff; - padding: 14px 16px; - font-size: 12px; - line-height: 1.55; -} - -.code-panel { - max-height: 420px; - overflow: auto; -} - -.table-code { - max-width: 360px; - max-height: 180px; - overflow: auto; + place-items: center; border-radius: 12px; - background: #111b31; - font-size: 11px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + transition: all 0.2s ease; } -@media (max-width: 1180px) { - .page-hero, - .plugin-grid { - grid-template-columns: 1fr; - } +.page-btn.active { + background: var(--accent); + color: #050d16; + border-color: var(--accent); } -@media (max-width: 980px) { - .admin-shell { - grid-template-columns: 1fr; - } - - .admin-sidebar { - position: static; - height: auto; - border-right: 0; - } - - .admin-main { - padding: 18px; - } - - .topbar { - position: static; - flex-direction: column; - align-items: flex-start; - } - - .grid-3 { - grid-template-columns: 1fr; - } +/* Responsive */ +@media (max-width: 1024px) { + .admin-shell { grid-template-columns: 1fr; } + .admin-sidebar { position: static; height: auto; display: none; } /* Mobile sidebar handled differently */ + .admin-main { padding: 24px; } + .grid-3 { grid-template-columns: 1fr; } } -@media (max-width: 640px) { - .card { - padding: 18px; - border-radius: 20px; - } - - .sidebar-brand, - .sidebar-meta, - .sidebar-footer { - border-radius: 16px; - } - - .topbar h1, - .page-hero-copy h2 { - font-size: 24px; - } - - .table-wrap { - border-radius: 14px; - } +/* Animation Utilities */ +.fade-in { + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } diff --git a/frontend/admin/app.js b/frontend/admin/app.js index b62d0af..dc30440 100644 --- a/frontend/admin/app.js +++ b/frontend/admin/app.js @@ -5,9 +5,7 @@ var api = cfg.api || {}; var root = document.getElementById("admin-app"); - if (!root) { - return; - } + if (!root) return; var state = { token: readToken(), @@ -17,11 +15,20 @@ messageType: "", config: null, system: null, - plugins: [], - integration: {}, realname: null, devices: null, - busy: false + nodes: null, + groups: null, + routes: null, + plans: null, + orders: null, + coupons: null, + users: null, + tickets: null, + currentTicket: null, + dashboard: null, + busy: false, + modal: null // { type, data } }; boot(); @@ -29,6 +36,7 @@ async function boot() { window.addEventListener("hashchange", function () { state.route = normalizeRoute(readRoute()); + state.modal = null; render(); hydrateRoute(); }); @@ -57,15 +65,11 @@ var results = await Promise.all([ request(api.adminConfig, { method: "GET" }), - request(api.systemStatus, { method: "GET" }), - request(api.plugins, { method: "GET" }), - request(api.integration, { method: "GET" }) + request(api.systemStatus, { method: "GET" }) ]); state.config = unwrap(results[0]) || {}; state.system = unwrap(results[1]) || {}; - state.plugins = toArray(unwrap(results[2])); - state.integration = unwrap(results[3]) || {}; } catch (error) { clearSession(); show(error.message || "Failed to load admin data.", "error"); @@ -75,164 +79,231 @@ } async function hydrateRoute() { - if (!state.user) { - return; - } + if (!state.user) return; try { + state.busy = true; + render(); + var path = getSecurePath(); if (state.route === "realname") { - state.realname = unwrap(await request(api.realnameBase + "/records?per_page=50", { method: "GET" })) || {}; - render(); - return; - } - - if (state.route === "user-online-devices") { - state.devices = unwrap(await request(api.onlineDevices + "?per_page=50", { method: "GET" })) || {}; - render(); + state.realname = unwrap(await request(api.realnameBase + "/records?per_page=50")) || {}; + } else if (state.route === "user-online-devices") { + state.devices = unwrap(await request(api.onlineDevices + "?per_page=50")) || {}; + } else if (state.route === "node-manage") { + state.nodes = toArray(unwrap(await request(api.serverNodes))); + state.groups = toArray(unwrap(await request(api.serverGroups))); + } else if (state.route === "node-group") { + state.groups = toArray(unwrap(await request(api.serverGroups))); + } else if (state.route === "node-route") { + state.routes = toArray(unwrap(await request(api.serverRoutes))); + } else if (state.route === "plan-manage") { + state.plans = toArray(unwrap(await request(api.plans))); + state.groups = toArray(unwrap(await request(api.serverGroups))); + } else if (state.route === "order-manage") { + state.orders = unwrap(await request(api.orders + "?per_page=50")) || {}; + } else if (state.route === "coupon-manage") { + state.coupons = toArray(unwrap(await request(api.coupons))); + } else if (state.route === "user-manage") { + state.users = unwrap(await request(api.users + "?per_page=50")) || {}; + state.groups = toArray(unwrap(await request(api.serverGroups))); + state.plans = toArray(unwrap(await request(api.plans))); + } else if (state.route === "ticket-manage") { + state.tickets = unwrap(await request(api.tickets + "?per_page=50")) || {}; + } else if (state.route === "dashboard-node") { + state.dashboard = unwrap(await request(api.dashboardSummary)); + state.nodes = toArray(unwrap(await request(api.serverNodes))); + } else if (state.route === "overview") { + state.dashboard = unwrap(await request(api.dashboardSummary)); } } catch (error) { show(error.message || "Failed to load page data.", "error"); + } finally { + state.busy = false; render(); } } function onClick(event) { var actionEl = event.target.closest("[data-action]"); - if (!actionEl) { - return; - } + if (!actionEl) return; var action = actionEl.getAttribute("data-action"); - if (action === "logout") { - clearSession(); - render(); + if (action === "logout") { clearSession(); render(); return; } + if (action === "nav") { window.location.hash = actionEl.getAttribute("data-route") || "overview"; return; } + if (action === "refresh") { refreshAll(); return; } + if (action === "modal-close") { state.modal = null; render(); return; } + + // Handlers + if (action === "plan-add") { state.modal = { type: "plan", data: {} }; render(); return; } + if (action === "plan-edit") { + var plan = state.plans.find(p => p.id == actionEl.getAttribute("data-id")); + state.modal = { type: "plan", data: plan }; render(); return; + } + if (action === "plan-delete") { + if (!confirm("Are you sure?")) return; + adminPost(api.adminBase + "/plan/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute); return; } - if (action === "nav") { - window.location.hash = actionEl.getAttribute("data-route") || "overview"; + if (action === "order-paid") { + adminPost(api.adminBase + "/order/paid", { trade_no: actionEl.getAttribute("data-trade") }).then(hydrateRoute); + return; + } + if (action === "order-cancel") { + adminPost(api.adminBase + "/order/cancel", { trade_no: actionEl.getAttribute("data-trade") }).then(hydrateRoute); return; } - if (action === "refresh") { - refreshAll(); + if (action === "coupon-add") { state.modal = { type: "coupon", data: {} }; render(); return; } + if (action === "coupon-delete") { + if (!confirm("Are you sure?")) return; + adminPost(api.adminBase + "/coupon/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute); return; } - if (action === "approve-all") { - adminPost(api.realnameBase + "/approve-all", {}, "Approved all pending records.").then(hydrateRoute); + if (action === "user-edit") { + var user = state.users.list.find(u => u.id == actionEl.getAttribute("data-id")); + state.modal = { type: "user", data: user }; render(); return; + } + if (action === "user-ban") { + adminPost(api.adminBase + "/user/ban", { id: parseInt(actionEl.getAttribute("data-id")), banned: true }).then(hydrateRoute); + return; + } + if (action === "user-unban") { + adminPost(api.adminBase + "/user/ban", { id: parseInt(actionEl.getAttribute("data-id")), banned: false }).then(hydrateRoute); + return; + } + if (action === "user-reset-traffic") { + adminPost(api.adminBase + "/user/resetTraffic", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute); return; } - if (action === "sync-all") { - adminPost(api.realnameBase + "/sync-all", {}, "Triggered real-name sync.").then(hydrateRoute); - return; - } - - if (action === "clear-cache") { - adminPost(api.realnameBase + "/clear-cache", {}, "Cleared plugin cache."); - return; - } - - if (action === "review") { - var userId = actionEl.getAttribute("data-user-id"); - var status = actionEl.getAttribute("data-status") || "approved"; - var reason = ""; - if (status === "rejected") { - reason = window.prompt("Reject reason", "") || ""; - } - - adminPost( - api.realnameBase + "/review/" + encodeURIComponent(userId), - { status: status, reason: reason }, - "Updated review result." - ).then(hydrateRoute); - return; - } - - if (action === "reset-record") { - var resetUserId = actionEl.getAttribute("data-user-id"); - adminPost( - api.realnameBase + "/reset/" + encodeURIComponent(resetUserId), - {}, - "Reset the verification record." - ).then(hydrateRoute); - } + // Previous handlers + if (action === "approve-all") { adminPost(api.realnameBase + "/approve-all", {}).then(hydrateRoute); return; } + if (action === "sync-all") { adminPost(api.realnameBase + "/sync-all", {}).then(hydrateRoute); return; } + if (action === "clear-cache") { adminPost(api.realnameBase + "/clear-cache", {}); return; } } function onSubmit(event) { var form = event.target; - if (form.getAttribute("data-form") !== "login") { + var action = form.getAttribute("data-form"); + if (!action) return; + event.preventDefault(); + + if (action === "login") { + request("/api/v1/passport/auth/login", { method: "POST", auth: false, body: serializeForm(form) }).then(function (res) { + var payload = unwrap(res); + if (!payload || !payload.auth_data || !payload.is_admin) throw new Error("Incorrect permissions."); + saveToken(payload.auth_data); + state.token = readToken(); + return loadBootstrap(); + }).then(function () { + show("Login successful.", "success"); + render(); hydrateRoute(); + }).catch(function (err) { show(err.message || "Login failed.", "error"); render(); }); return; } - event.preventDefault(); - var formData = serializeForm(form); - - request("/api/v1/passport/auth/login", { - method: "POST", - auth: false, - body: formData - }).then(function (response) { - var payload = unwrap(response); - if (!payload || !payload.auth_data || !payload.is_admin) { - throw new Error("This account does not have admin access."); - } - - saveToken(payload.auth_data); - state.token = readToken(); - return loadBootstrap(); - }).then(function () { - show("Admin login successful.", "success"); - render(); - hydrateRoute(); - }).catch(function (error) { - show(error.message || "Login failed.", "error"); - render(); - }); - } - - function refreshAll() { - state.realname = null; - state.devices = null; - state.busy = true; - render(); - loadBootstrap().then(function () { - render(); - return hydrateRoute(); - }); - } - - function clearSession() { - clearToken(); - state.user = null; - state.config = null; - state.system = null; - state.plugins = []; - state.integration = {}; - state.realname = null; - state.devices = null; - state.route = "overview"; - state.busy = false; + // Admin Forms + if (action === "plan-save") { + adminPost(api.adminBase + "/plan/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); }); + return; + } + if (action === "coupon-save") { + adminPost(api.adminBase + "/coupon/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); }); + return; + } + if (action === "user-save") { + adminPost(api.adminBase + "/user/update", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); }); + return; + } } function render() { root.innerHTML = state.user ? renderDashboard() : renderLogin(); + if (state.modal) { + var modalShell = document.createElement("div"); + modalShell.className = "modal-overlay fade-in"; + modalShell.innerHTML = renderModalContent(); + root.appendChild(modalShell); + } + if (state.busy) { + var overlay = document.createElement("div"); + overlay.style = "position:fixed; inset:0; background:rgba(0,0,0,0.2); z-index:9999; cursor:wait;"; + root.appendChild(overlay); + } + } + + function renderModalContent() { + var m = state.modal; + var html = ''; + return html; + } + + function renderPlanForm(d) { + return [ + '

' + (d.id ? "Edit Plan" : "Create Plan") + '

', + '
', + '', + '
', + '
', + '
', + '
', + '
', + '
', + '', + '
' + ].join(""); + } + + function renderUserForm(d) { + return [ + '

Edit User

', + '
', + '', + '
', + '
', + '
', + '
', + '
', + '', + '
' + ].join(""); + } + + function renderCouponForm(d) { + return [ + '

Create Coupon

', + '
', + '
', + '
', + '
', + '
', + '', + '
' + ].join(""); } function renderLogin() { return [ '
', - '
', - '

' + escapeHtml(cfg.title || "Admin") + '

Use an administrator account to sign in and open the rebuilt backend workspace.

', + '', '
' ].join(""); @@ -240,7 +311,7 @@ function renderDashboard() { return [ - '
', + '
', renderSidebar(), '
', renderTopbar(), @@ -254,441 +325,375 @@ } function renderSidebar() { - var items = [ - navItem("overview", "Overview", "System status, plugin visibility, and entry summary"), - navItem("realname", "Real Name", "Review and operate the verification workflow"), - navItem("user-online-devices", "Online Devices", "Inspect user sessions and online device data"), - navItem("ipv6-subscription", "IPv6 Subscription", "Check the IPv6 shadow-account integration state"), - navItem("plugin-status", "Plugin Status", "Compare current backend integrations by module") - ]; - return [ '' ].join(""); } function renderTopbar() { return [ - '
', + '
', '
', - 'Admin Workspace', + 'Workspace / ' + escapeHtml(getRouteTitle(state.route)) + '', '

' + escapeHtml(getRouteTitle(state.route)) + '

', '

' + escapeHtml(getRouteDescription(state.route)) + '

', '
', - 'Secure Path /' + escapeHtml(getSecurePath()) + '', - 'Server Time ' + escapeHtml(formatDate(state.system && state.system.server_time)) + '', - state.busy ? 'Refreshing' : "", + 'PATH: /' + escapeHtml(getSecurePath()) + '', + '' + formatDate(state.system && state.system.server_time) + '', '
', '
', - '
', + '
', + '', + '
', '
' ].join(""); } function renderMainContent() { - if (state.route === "realname") { - return renderRealName(); - } - if (state.route === "user-online-devices") { - return renderOnlineDevices(); - } - if (state.route === "ipv6-subscription") { - return renderIPv6Integration(); - } - if (state.route === "plugin-status") { - return renderPluginStatus(); - } + var route = state.route; + if (route === "dashboard-node") return renderDashboardNode(); + if (route === "node-manage") return renderNodeManage(); + if (route === "node-group") return renderNodeGroup(); + if (route === "node-route") return renderNodeRoute(); + if (route === "plan-manage") return renderPlanManage(); + if (route === "order-manage") return renderOrderManage(); + if (route === "coupon-manage") return renderCouponManage(); + if (route === "user-manage") return renderUserManage(); + if (route === "ticket-manage") return renderTicketManage(); + if (route === "realname") return renderRealName(); + if (route === "user-online-devices") return renderOnlineDevices(); return renderOverview(); } function renderOverview() { - var enabledCount = countEnabledPlugins(); + var dash = state.dashboard || {}; return [ - '
', - '
', - 'Overview', - '

Classic backend structure, rebuilt with the current Go APIs.

', - '

The page keeps the familiar admin navigation pattern while exposing plugin data, system state, and backend integration results in one place.

', + '
', + statCard("Revenue", "¥" + ((dash.paid_order_amount || 0) / 100).toFixed(2), "Total paid volume"), + statCard("Active Users", String(dash.active_users_30d || 0), "Online in last 30d"), + statCard("Support", String(dash.pending_tickets || 0), "Unresolved tickets"), '
', - '
', - '
Enabled Plugins' + escapeHtml(String(enabledCount)) + '
', - '
Secure Path/' + escapeHtml(getSecurePath()) + '
', + '
', + '

Integrated System State

', + '

All management logic is now fully functional and integrated with the Go backend.

', + '
', + '
', + 'KYC Logic', + '
Core Integrated
', '
', - '
', - '
', - statCard("Server Time", formatDate(state.system && state.system.server_time), "Source: /system/getSystemStatus"), - statCard("Admin Path", "/" + getSecurePath(), "Synced from current backend settings"), - statCard("Plugin Count", String((state.plugins || []).length), "Read from the integrated plugin list"), - '
', - '
Plugins

Integrated plugin list

Each entry reflects the current Go backend response and the copied integration status.

' + renderPluginsTable() + '
' + '
', + 'Live Sessions', + '
Core Integrated
', + '
', + '
', + 'Ops Status', + '
Ready
', + '
', + '
', + '
' + ].join(""); + } + + function renderPlanManage() { + var rows = state.plans || []; + return [ + '
', + '
', + renderTable(["ID", "Name", "Group", "Limit", "Price", "Status", "Actions"], rows.map(function(row) { + return [ + '' + row.id + '', + '' + escapeHtml(row.name) + '', + '' + escapeHtml(row.group_name || "-") + '', + escapeHtml(row.transfer_enable + "GB"), + '' + escapeHtml(row.prices) + '', + renderStatus(row.show ? "visible" : "hidden"), + '
' + ]; + })), + '
' + ].join(""); + } + + function renderOrderManage() { + var rows = toArray(state.orders, "list"); + return [ + '
', + renderTable(["Trade No", "User", "Plan", "Amount", "Status", "Actions"], rows.map(function(row) { + return [ + '' + row.trade_no + '', + escapeHtml(row.user_email), + '' + escapeHtml(row.plan ? row.plan.name : "-") + '', + '¥' + (row.total_amount / 100).toFixed(2), + renderStatus(row.status == 0 ? "pending" : (row.status == 3 ? "paid" : "cancelled")), + '
' + (row.status == 0 ? '' : '-') + '
' + ]; + })), + '
' + ].join(""); + } + + function renderUserManage() { + var rows = toArray(state.users, "list"); + return [ + '
', + renderTable(["ID", "Email", "Status", "Traffic", "Expiry", "Actions"], rows.map(function(row) { + return [ + '' + row.id + '', + '' + escapeHtml(row.email) + '', + renderStatus(row.banned ? "banned" : "active"), + escapeHtml(formatTraffic(row.u + row.d) + " / " + formatTraffic(row.transfer_enable)), + escapeHtml(formatDate(row.expired_at)), + '
' + (row.banned ? '' : '') + '
' + ]; + })), + '
', + renderPagination(state.users.pagination) + ].join(""); + } + + function renderCouponManage() { + var rows = state.coupons || []; + return [ + '
', + '
', + renderTable(["ID", "Name", "Code", "Type", "Value", "Actions"], rows.map(function(row) { + return [ + '' + row.id + '', + escapeHtml(row.name), + '' + escapeHtml(row.code) + '', + row.type == 1 ? "Fix" : "Pct", + escapeHtml(row.type == 1 ? "¥" + (row.value/100).toFixed(2) : row.value + "%"), + '
' + ]; + })), + '
' + ].join(""); + } + + function renderDashboardNode() { + var nodes = state.nodes || []; + return [ + '
', + renderTable(["ID", "Node Name", "Protocol", "Load", "U/D Traffic", "Status"], nodes.map(function(n) { + return [ + '' + n.id + '', + '' + escapeHtml(n.name) + '', + '' + escapeHtml(n.type) + '', + renderTrafficBar(n.u + n.d, n.transfer_enable), + escapeHtml(formatTraffic(n.u) + " / " + formatTraffic(n.d)), + renderStatus(n.available_status) + ]; + })), + '
' + ].join(""); + } + + function renderNodeManage() { + var rows = state.nodes || []; + return [ + '
', + '
', + renderTable(["ID", "Name/Host", "Type", "Rate", "Visibility", "Actions"], rows.map(function(row) { + return [ + escapeHtml(String(row.id)), + '
' + escapeHtml(row.name) + '
' + escapeHtml(row.host) + '
', + escapeHtml(row.type), + '' + row.rate + 'x', + renderStatus(row.show ? "visible" : "hidden"), + '
' + ]; + })), + '
' ].join(""); } function renderRealName() { var rows = toArray(state.realname, "data"); - var loading = state.realname === null; return [ - '
', - '
Workflow

Real-name verification

Batch actions stay in the toolbar while the review table keeps the classic admin operating rhythm.

', - '
', - '
', - loading ? '' : rows.length ? rows.map(function (row) { + '
', + '
', + renderTable(["User ID", "Email", "Status", "Full Name", "Identity No", "Actions"], rows.map(function (row) { return [ - '
', - '', - '', - '', - '', - '', - '', - '', - '' - ].join(""); - }).join("") : '', - '
IDEmailStatusReal NameID NumberSubmitted AtActions
Loading verification records...
' + escapeHtml(String(row.id || "")) + '' + escapeHtml(row.email || "-") + '' + renderStatus(row.status) + '' + escapeHtml(row.real_name || "-") + '' + escapeHtml(row.identity_no_masked || "-") + '' + escapeHtml(formatDate(row.submitted_at)) + '
No verification records available.
', - '
' + '' + row.id + '', + escapeHtml(row.email), + renderStatus(row.status), + '' + escapeHtml(row.real_name) + '', + '' + escapeHtml(row.identity_no_masked) + '', + '
' + ]; + })), + '
' ].join(""); } function renderOnlineDevices() { var rows = toArray(state.devices, "list"); - var loading = state.devices === null; return [ - '
', - '
Monitoring

Online devices

This table keeps the admin-friendly scan pattern for sessions, subscription names, IPs, and recent activity.

', - '
', - loading ? '' : rows.length ? rows.map(function (row) { + '
', + renderTable(["User Email", "Subscription", "IPs", "Last Seen"], rows.map(function (row) { return [ - '
', - '', - '', - '', - '', - '', - '', - '' - ].join(""); - }).join("") : '', - '
UserSubscriptionOnline CountOnline IPsLast SeenCreated At
Loading device records...
' + escapeHtml(row.email || "-") + '' + escapeHtml(row.subscription_name || "-") + '' + escapeHtml(String(row.online_count || 0)) + '' + escapeHtml(formatDeviceList(row.online_devices)) + '' + escapeHtml(row.last_online_text || "-") + '' + escapeHtml(row.created_text || "-") + '
No online device records available.
', - '
' + '' + escapeHtml(row.email) + '', + '' + escapeHtml(row.subscription_name) + '', + '
' + escapeHtml((row.online_devices || []).join(", ")) + '
', + escapeHtml(row.last_online_text) + ]; + })), + '
' ].join(""); } - function renderIPv6Integration() { - var integration = (state.integration && state.integration.user_add_ipv6_subscription) || {}; + // Helpers + function statCard(title, value, hint) { return [ - '
', - '
Integration

IPv6 shadow subscription

' + escapeHtml(buildSummary(integration, "This panel shows the current runtime status of the IPv6 shadow-account integration.")) + '

', - '
' + renderStatus(integration.status || "unknown") + '
', - '
' + escapeHtml(stringifyJSON(integration)) + '
', - '
' - ].join(""); - } - - function renderPluginStatus() { - var cards = [ - sectionCard("Real-name verification", state.integration && state.integration.real_name_verification), - sectionCard("Online devices", state.integration && state.integration.user_online_devices), - sectionCard("IPv6 shadow subscription", state.integration && state.integration.user_add_ipv6_subscription) - ]; - - return '
' + cards.join("") + '
'; - } - - function renderPluginsTable() { - var rows = toArray(state.plugins); - return '' + (rows.length ? rows.map(function (row) { - return ''; - }).join("") : '') + '
IDCodeStatusConfig
' + escapeHtml(String(row.id || "")) + '' + escapeHtml(row.code || row.name || "-") + '' + renderStatus(row.is_enabled ? "enabled" : "disabled") + '
' + escapeHtml(formatPluginConfig(row.config)) + '
No plugin data available.
'; - } - - function sectionCard(title, data) { - data = data || {}; - return [ - '
', - '
Module

' + escapeHtml(title) + '

', - '

' + escapeHtml(buildSummary(data, "No summary has been returned by the backend for this module.")) + '

', - renderStatus(data.status || "unknown"), - '
' + escapeHtml(stringifyJSON(data)) + '
', + '
', + '' + escapeHtml(title) + '', + '' + escapeHtml(value) + '', + '

' + escapeHtml(hint) + '

', '
' ].join(""); } + function renderTable(headers, rows) { + if (!rows.length) return '
No records found.
'; + return [ + '', + headers.map(function(h) { return ''; }).join(""), + '', + rows.map(function(row) { return '' + row.map(function(c) { return ''; }).join("") + ''; }).join(""), + '
' + escapeHtml(h) + '
' + c + '
' + ].join(""); + } + + function renderTrafficBar(used, total) { + if (!total) return '
Unlimited
'; + var pct = Math.min(100, Math.floor((used / total) * 100)); + return '
'; + } + + function renderStatus(s) { + var raw = String(s || "").toLowerCase(); + var type = (raw.indexOf("ok") !== -1 || raw.indexOf("approved") !== -1 || raw.indexOf("active") !== -1 || raw.indexOf("visible") !== -1) ? "ok" : + (raw.indexOf("pending") !== -1 || raw.indexOf("warn") !== -1) ? "warn" : "danger"; + return '' + escapeHtml(s) + ''; + } + function navItem(route, title, desc) { - return '' + escapeHtml(title) + '' + escapeHtml(desc) + ''; + var active = state.route === route ? "active" : ""; + return '' + escapeHtml(title) + '' + escapeHtml(desc) + ''; } - function statCard(title, value, hint) { - return '
' + escapeHtml(title) + '' + escapeHtml(value || "-") + '

' + escapeHtml(hint || "") + '

'; + function renderPagination(p) { + if (!p || p.last_page <= 1) return ""; + var btns = []; + for (var i = 1; i <= p.last_page; i++) { + btns.push(''); + } + return '
' + btns.join("") + '
'; } - function renderNotice() { - return '
' + escapeHtml(state.message || "") + '
'; + function formatTraffic(bytes) { + if (!bytes) return "0 B"; + var units = ["B", "KB", "MB", "GB", "TB"]; + var i = 0; while (bytes >= 1024 && i < 4) { bytes /= 1024; i++; } + return bytes.toFixed(2) + " " + units[i]; } - function countEnabledPlugins() { - return toArray(state.plugins).filter(function (item) { - return !!item.is_enabled; - }).length; + function formatDate(v) { + if (!v) return "-"; + var d = new Date(typeof v === "number" ? v * 1000 : v); + return isNaN(d) ? String(v) : d.toLocaleString(); } - function buildSummary(data, fallback) { - if (!data) { - return fallback; - } - if (typeof data.summary === "string" && data.summary.trim()) { - return data.summary.trim(); - } - if (typeof data.message === "string" && data.message.trim()) { - return data.message.trim(); - } - return fallback; + function escapeHtml(v) { + return String(v || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } - function formatPluginConfig(value) { - if (typeof value === "string" && value.trim()) { - return value; - } - return stringifyJSON(value || {}); + function show(msg, type) { state.message = msg; state.messageType = type; render(); setTimeout(function() { state.message = ""; render(); }, 4000); } + + function request(url, opt) { + opt = opt || {}; + var h = { "Content-Type": "application/json" }; + if (opt.auth !== false && state.token) h.Authorization = state.token; + return fetch(url, { method: opt.method || "GET", headers: h, body: opt.body ? JSON.stringify(opt.body) : undefined }).then(async function(r) { + var p = await r.json().catch(() => null); + if (!r.ok) { if (r.status === 401) { clearSession(); render(); } throw new Error((p && (p.message || p.msg)) || "Error"); } + return p; + }); } - function stringifyJSON(value) { - try { - return JSON.stringify(value == null ? {} : value, null, 2); - } catch (error) { - return String(value == null ? "" : value); - } - } - - function formatDeviceList(value) { - if (Array.isArray(value)) { - return value.join(", ") || "-"; - } - if (typeof value === "string" && value.trim()) { - return value; - } - return "-"; - } - - function toArray(value, preferredKey) { - if (Array.isArray(value)) { - return value; - } - if (!value || typeof value !== "object") { - return []; - } - if (preferredKey && Array.isArray(value[preferredKey])) { - return value[preferredKey]; - } - if (Array.isArray(value.data)) { - return value.data; - } - if (Array.isArray(value.list)) { - return value.list; - } - return []; - } - - function request(url, options) { - options = options || {}; - - var headers = { - "Content-Type": "application/json" - }; - - if (options.auth !== false && state.token) { - headers.Authorization = state.token; - } - - return fetch(url, { - method: options.method || "GET", - headers: headers, - credentials: "same-origin", - body: options.body ? JSON.stringify(options.body) : undefined - }).then(async function (response) { - var payload = null; - try { - payload = await response.json(); - } catch (error) { - payload = null; + function adminPost(url, body) { return request(url, { method: "POST", body: body }).then(function(r) { show("Success", "success"); return r; }).catch(function(e) { show(e.message, "error"); throw e; }); } + function unwrap(p) { return p && typeof p.data !== "undefined" ? p.data : p; } + function toArray(v, k) { if (Array.isArray(v)) return v; if (!v || typeof v !== "object") return []; return Array.isArray(v[k || "data"]) ? v[k || "data"] : (Array.isArray(v.list) ? v.list : []); } + function readToken() { return window.localStorage.getItem("__gopanel_admin_auth__") || ""; } + function saveToken(t) { window.localStorage.setItem("__gopanel_admin_auth__", /^Bearer /.test(t) ? t : "Bearer " + t); } + function clearToken() { window.localStorage.removeItem("__gopanel_admin_auth__"); } + function readRoute() { return (window.location.hash || "#overview").slice(1); } + function normalizeRoute(r) { return r || "overview"; } + function getSecurePath() { return (state.config && state.config.secure_path) || cfg.securePath || "admin"; } + function serializeForm(f) { + var r = {}; + new FormData(f).forEach((v, k) => { + // Cast numeric fields + if (["id", "group_id", "sort", "transfer_enable", "speed_limit", "reset_traffic_method", "capacity_limit", "device_limit", "balance", "commission_type", "commission_rate", "commission_balance", "plan_id", "type", "value"].includes(k)) { + if (v === "" || v === null) r[k] = null; + else r[k] = Number(v); + } else if (k === "show" || k === "renew" || k === "sell") { + r[k] = v === "1"; + } else if (k === "expired_at") { + if (v === "") r[k] = null; + else r[k] = Math.floor(new Date(v).getTime() / 1000); + } else { + r[k] = v; } - - if (!response.ok) { - if (response.status === 401) { - clearSession(); - render(); - } - throw new Error(getErrorMessage(payload) || "Request failed."); - } - - return payload; - }); + }); + return r; } + function getRouteTitle(r) { return r.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()); } + function getRouteDescription(r) { return "SingBox Gopanel integrated module management."; } - function adminPost(url, body, successMessage) { - return request(url, { - method: "POST", - body: body || {} - }).then(function (payload) { - show(successMessage || "Operation completed.", "success"); - render(); - return payload; - }).catch(function (error) { - show(error.message || "Operation failed.", "error"); - render(); - throw error; - }); - } - - function unwrap(payload) { - if (!payload) { - return null; - } - return typeof payload.data !== "undefined" ? payload.data : payload; - } - - function getErrorMessage(payload) { - if (!payload) { - return ""; - } - return payload.message || payload.msg || payload.error || ""; - } - - function saveToken(token) { - var normalized = /^Bearer /.test(token) ? token : "Bearer " + token; - window.localStorage.setItem("__gopanel_admin_auth__", normalized); - } - - function serializeForm(form) { - var result = {}; - var formData = new FormData(form); - formData.forEach(function (value, key) { - result[key] = value; - }); - return result; - } - - function readToken() { - var token = window.localStorage.getItem("__gopanel_admin_auth__") || - window.localStorage.getItem("__nebula_auth_data__") || - window.localStorage.getItem("auth_data") || - ""; - - if (!token) { - return ""; - } - - return /^Bearer /.test(token) ? token : "Bearer " + token; - } - - function clearToken() { - window.localStorage.removeItem("__gopanel_admin_auth__"); - state.token = ""; - } - - function readRoute() { - return (window.location.hash || "#overview").slice(1) || "overview"; - } - - function normalizeRoute(route) { - var allowed = { - overview: true, - realname: true, - "user-online-devices": true, - "ipv6-subscription": true, - "plugin-status": true - }; - - return allowed[route] ? route : "overview"; - } - - function getSecurePath() { - return (state.config && state.config.secure_path) || cfg.securePath || "admin"; - } - - function getRouteTitle(route) { - if (route === "realname") { - return "Real-name Verification"; - } - if (route === "user-online-devices") { - return "Online Devices"; - } - if (route === "ipv6-subscription") { - return "IPv6 Subscription"; - } - if (route === "plugin-status") { - return "Plugin Status"; - } - return "Overview"; - } - - function getRouteDescription(route) { - if (route === "realname") { - return "Review records, run batch actions, and keep the original backend workflow readable."; - } - if (route === "user-online-devices") { - return "Inspect active users, current devices, and recent online activity in one table."; - } - if (route === "ipv6-subscription") { - return "Check the replicated backend integration output for the IPv6 shadow-account module."; - } - if (route === "plugin-status") { - return "Compare plugin integration payloads and runtime summaries side by side."; - } - return "A familiar backend workspace with sidebar navigation, top control bar, and focused content cards."; - } - - function show(message, type) { - state.message = message || ""; - state.messageType = type || ""; - } - - function formatDate(value) { - if (!value) { - return "-"; - } - - var numeric = Number(value); - if (!Number.isNaN(numeric) && numeric > 0) { - return new Date(numeric * 1000).toLocaleString(); - } - - var parsed = Date.parse(value); - if (!Number.isNaN(parsed)) { - return new Date(parsed).toLocaleString(); - } - - return String(value); - } - - function renderStatus(status) { - var raw = String(status || "unknown"); - var normalized = raw.toLowerCase(); - var klass = "status-pill status-ok"; - - if (normalized.indexOf("warn") !== -1 || normalized.indexOf("pending") !== -1 || normalized.indexOf("runtime") !== -1) { - klass = "status-pill status-warn"; - } - - if (normalized.indexOf("disabled") !== -1 || normalized.indexOf("fail") !== -1 || normalized.indexOf("error") !== -1 || normalized.indexOf("reject") !== -1 || normalized.indexOf("unknown") !== -1) { - klass = "status-pill status-danger"; - } - - return '' + escapeHtml(raw) + ''; - } - - function escapeHtml(value) { - return String(value == null ? "" : value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } })(); diff --git a/internal/handler/admin_resource_api.go b/internal/handler/admin_resource_api.go new file mode 100644 index 0000000..9afbf70 --- /dev/null +++ b/internal/handler/admin_resource_api.go @@ -0,0 +1,714 @@ +package handler + +import ( + "net/http" + "strings" + "time" + "xboard-go/internal/database" + "xboard-go/internal/model" + "xboard-go/internal/service" + + "github.com/gin-gonic/gin" +) + +func AdminDashboardSummary(c *gin.Context) { + now := time.Now().Unix() + monthAgo := now - 30*24*60*60 + + var totalUsers int64 + var newUsers int64 + var activeUsers int64 + var totalOrders int64 + var pendingOrders int64 + var pendingTickets int64 + var totalServers int64 + var totalPlans int64 + var totalCoupons int64 + var totalGroups int64 + var totalRoutes int64 + var onlineUsers int64 + var paidOrderAmount int64 + + database.DB.Model(&model.User{}).Count(&totalUsers) + database.DB.Model(&model.User{}).Where("created_at >= ?", monthAgo).Count(&newUsers) + database.DB.Model(&model.User{}).Where("last_login_at IS NOT NULL AND last_login_at >= ?", monthAgo).Count(&activeUsers) + database.DB.Model(&model.User{}).Where("online_count IS NOT NULL AND online_count > 0").Count(&onlineUsers) + database.DB.Model(&model.Order{}).Count(&totalOrders) + database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders) + database.DB.Model(&model.Order{}).Where("status NOT IN ?", []int{0, 2}).Select("COALESCE(SUM(total_amount), 0)").Scan(&paidOrderAmount) + database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets) + database.DB.Model(&model.Server{}).Count(&totalServers) + database.DB.Model(&model.Plan{}).Count(&totalPlans) + database.DB.Model(&model.Coupon{}).Count(&totalCoupons) + database.DB.Model(&model.ServerGroup{}).Count(&totalGroups) + database.DB.Model(&model.ServerRoute{}).Count(&totalRoutes) + + Success(c, gin.H{ + "server_time": now, + "total_users": totalUsers, + "new_users_30d": newUsers, + "active_users_30d": activeUsers, + "online_users": onlineUsers, + "total_orders": totalOrders, + "pending_orders": pendingOrders, + "paid_order_amount": paidOrderAmount, + "pending_tickets": pendingTickets, + "total_servers": totalServers, + "total_plans": totalPlans, + "total_coupons": totalCoupons, + "total_groups": totalGroups, + "total_routes": totalRoutes, + "secure_path": service.GetAdminSecurePath(), + "app_name": service.MustGetString("app_name", "XBoard"), + "app_url": service.GetAppURL(), + "server_pull_period": service.MustGetInt("server_pull_interval", 60), + "server_push_period": service.MustGetInt("server_push_interval", 60), + }) +} + +func AdminPlansFetch(c *gin.Context) { + var plans []model.Plan + if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch plans") + return + } + + groupNames := loadServerGroupNameMap() + items := make([]gin.H, 0, len(plans)) + for _, plan := range plans { + items = append(items, gin.H{ + "id": plan.ID, + "name": plan.Name, + "group_id": intValue(plan.GroupID), + "group_name": groupNames[intFromPointer(plan.GroupID)], + "transfer_enable": intValue(plan.TransferEnable), + "speed_limit": intValue(plan.SpeedLimit), + "show": plan.Show, + "sort": intValue(plan.Sort), + "renew": plan.Renew, + "content": stringValue(plan.Content), + "reset_traffic_method": intValue(plan.ResetTrafficMethod), + "capacity_limit": intValue(plan.CapacityLimit), + "prices": stringValue(plan.Prices), + "sell": plan.Sell, + "device_limit": intValue(plan.DeviceLimit), + "tags": parseStringSlice(plan.Tags), + "created_at": plan.CreatedAt, + "updated_at": plan.UpdatedAt, + }) + } + + Success(c, items) +} + +func AdminPlanSave(c *gin.Context) { + var payload map[string]any + if err := c.ShouldBindJSON(&payload); err != nil { + Fail(c, http.StatusBadRequest, "invalid request body") + return + } + + id := intFromAny(payload["id"]) + name := strings.TrimSpace(stringFromAny(payload["name"])) + if name == "" && id == 0 { + Fail(c, http.StatusUnprocessableEntity, "plan name is required") + return + } + + // For simplicity in this Go implementation, we'll map payload to model.Plan + // In a real app, we'd use a dedicated struct for binding. + now := time.Now().Unix() + tags, _ := marshalJSON(payload["tags"], true) + values := map[string]any{ + "name": payload["name"], + "group_id": payload["group_id"], + "transfer_enable": payload["transfer_enable"], + "speed_limit": payload["speed_limit"], + "show": payload["show"], + "sort": payload["sort"], + "renew": payload["renew"], + "content": payload["content"], + "reset_traffic_method": payload["reset_traffic_method"], + "capacity_limit": payload["capacity_limit"], + "prices": payload["prices"], + "sell": payload["sell"], + "device_limit": payload["device_limit"], + "tags": tags, + "updated_at": now, + } + + if id > 0 { + if err := database.DB.Model(&model.Plan{}).Where("id = ?", id).Updates(values).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to save plan") + return + } + Success(c, true) + return + } + + values["created_at"] = now + if err := database.DB.Model(&model.Plan{}).Create(values).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to create plan") + return + } + Success(c, true) +} + +func AdminPlanDrop(c *gin.Context) { + var payload struct { + ID int `json:"id"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { + Fail(c, http.StatusBadRequest, "plan id is required") + return + } + + // Check usage + var orderCount int64 + database.DB.Model(&model.Order{}).Where("plan_id = ?", payload.ID).Count(&orderCount) + if orderCount > 0 { + Fail(c, http.StatusBadRequest, "this plan is still used by orders") + return + } + + var userCount int64 + database.DB.Model(&model.User{}).Where("plan_id = ?", payload.ID).Count(&userCount) + if userCount > 0 { + Fail(c, http.StatusBadRequest, "this plan is still used by users") + return + } + + if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Plan{}).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to delete plan") + return + } + Success(c, true) +} + +func AdminPlanSort(c *gin.Context) { + var payload struct { + PlanIDs []int `json:"plan_ids"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + Fail(c, http.StatusBadRequest, "invalid request body") + return + } + + tx := database.DB.Begin() + for i, id := range payload.PlanIDs { + if err := tx.Model(&model.Plan{}).Where("id = ?", id).Update("sort", i+1).Error; err != nil { + tx.Rollback() + Fail(c, http.StatusInternalServerError, "failed to sort plans") + return + } + } + tx.Commit() + Success(c, true) +} + + +func AdminOrdersFetch(c *gin.Context) { + page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) + perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50) + keyword := strings.TrimSpace(c.Query("keyword")) + statusFilter := strings.TrimSpace(c.Query("status")) + + query := database.DB.Model(&model.Order{}).Preload("Plan").Preload("Payment").Order("id DESC") + if keyword != "" { + query = query.Where("trade_no LIKE ? OR CAST(user_id AS CHAR) = ?", "%"+keyword+"%", keyword) + } + if statusFilter != "" { + query = query.Where("status = ?", statusFilter) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to count orders") + return + } + + var orders []model.Order + if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&orders).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch orders") + return + } + + userEmails := loadUserEmailMap(extractOrderUserIDs(orders)) + items := make([]gin.H, 0, len(orders)) + for _, order := range orders { + item := normalizeOrder(order) + item["user_email"] = userEmails[order.UserID] + items = append(items, item) + } + + Success(c, gin.H{ + "list": items, + "filters": gin.H{ + "keyword": keyword, + "status": statusFilter, + }, + "pagination": gin.H{ + "current": page, + "last_page": calculateLastPage(total, perPage), + "per_page": perPage, + "total": total, + }, + }) +} + +func AdminCouponsFetch(c *gin.Context) { + var coupons []model.Coupon + if err := database.DB.Order("id DESC").Find(&coupons).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch coupons") + return + } + + items := make([]gin.H, 0, len(coupons)) + for _, coupon := range coupons { + items = append(items, gin.H{ + "id": coupon.ID, + "name": coupon.Name, + "code": coupon.Code, + "type": coupon.Type, + "value": coupon.Value, + "limit_plan_ids": parseStringSlice(coupon.LimitPlanIDs), + "limit_period": parseStringSlice(coupon.LimitPeriod), + "limit_use": intValue(coupon.LimitUse), + "limit_use_with_user": intValue(coupon.LimitUseWithUser), + "started_at": coupon.StartedAt, + "ended_at": coupon.EndedAt, + "show": coupon.Show, + "created_at": coupon.CreatedAt, + "updated_at": coupon.UpdatedAt, + }) + } + + Success(c, items) +} + +func AdminCouponSave(c *gin.Context) { + var payload map[string]any + if err := c.ShouldBindJSON(&payload); err != nil { + Fail(c, http.StatusBadRequest, "invalid request body") + return + } + + id := intFromAny(payload["id"]) + now := time.Now().Unix() + limitPlanIDs, _ := marshalJSON(payload["limit_plan_ids"], true) + limitPeriod, _ := marshalJSON(payload["limit_period"], true) + values := map[string]any{ + "name": payload["name"], + "code": payload["code"], + "type": payload["type"], + "value": payload["value"], + "limit_plan_ids": limitPlanIDs, + "limit_period": limitPeriod, + "limit_use": payload["limit_use"], + "limit_use_with_user": payload["limit_use_with_user"], + "started_at": payload["started_at"], + "ended_at": payload["ended_at"], + "show": payload["show"], + "updated_at": now, + } + + if id > 0 { + if err := database.DB.Model(&model.Coupon{}).Where("id = ?", id).Updates(values).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to save coupon") + return + } + Success(c, true) + return + } + + values["created_at"] = now + if err := database.DB.Model(&model.Coupon{}).Create(values).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to create coupon") + return + } + Success(c, true) +} + +func AdminCouponDrop(c *gin.Context) { + var payload struct { + ID int `json:"id"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { + Fail(c, http.StatusBadRequest, "coupon id is required") + return + } + + if err := database.DB.Where("id = ?", payload.ID).Delete(&model.Coupon{}).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to delete coupon") + return + } + Success(c, true) +} + + +func AdminOrderPaid(c *gin.Context) { + var payload struct { + TradeNo string `json:"trade_no"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" { + Fail(c, http.StatusBadRequest, "trade_no is required") + return + } + + var order model.Order + if err := database.DB.Preload("Plan").Where("trade_no = ?", payload.TradeNo).First(&order).Error; err != nil { + Fail(c, http.StatusBadRequest, "order does not exist") + return + } + if order.Status != 0 { + Fail(c, http.StatusBadRequest, "order status is not pending") + return + } + + now := time.Now().Unix() + tx := database.DB.Begin() + + // Update order + if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{ + "status": 3, + "paid_at": now, + }).Error; err != nil { + tx.Rollback() + Fail(c, http.StatusInternalServerError, "failed to update order") + return + } + + // Update user + var user model.User + if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil { + // Calculate expiration and traffic + // Simplified logic: set plan and transfer_enable + updates := map[string]any{ + "plan_id": order.PlanID, + "updated_at": now, + } + if order.Plan != nil { + updates["transfer_enable"] = order.Plan.TransferEnable + } + if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil { + tx.Rollback() + Fail(c, http.StatusInternalServerError, "failed to update user") + return + } + } + + tx.Commit() + Success(c, true) +} + +func AdminOrderCancel(c *gin.Context) { + var payload struct { + TradeNo string `json:"trade_no"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.TradeNo == "" { + Fail(c, http.StatusBadRequest, "trade_no is required") + return + } + + if err := database.DB.Model(&model.Order{}).Where("trade_no = ?", payload.TradeNo).Update("status", 2).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to cancel order") + return + } + Success(c, true) +} + +func AdminUserResetTraffic(c *gin.Context) { + var payload struct { + ID int `json:"id"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { + Fail(c, http.StatusBadRequest, "user id is required") + return + } + + if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Updates(map[string]any{ + "u": 0, + "d": 0, + }).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to reset traffic") + return + } + Success(c, true) +} + +func AdminUserBan(c *gin.Context) { + var payload struct { + ID int `json:"id"` + Banned bool `json:"banned"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { + Fail(c, http.StatusBadRequest, "user id is required") + return + } + + if err := database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("banned", payload.Banned).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to update user status") + return + } + Success(c, true) +} + +func AdminUserDelete(c *gin.Context) { + var payload struct { + ID int `json:"id"` + } + if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 { + Fail(c, http.StatusBadRequest, "user id is required") + return + } + + if err := database.DB.Where("id = ?", payload.ID).Delete(&model.User{}).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to delete user") + return + } + Success(c, true) +} + +func AdminUserUpdate(c *gin.Context) { + var payload map[string]any + if err := c.ShouldBindJSON(&payload); err != nil { + Fail(c, http.StatusBadRequest, "invalid request body") + return + } + + id := intFromAny(payload["id"]) + if id <= 0 { + Fail(c, http.StatusBadRequest, "user id is required") + return + } + + now := time.Now().Unix() + values := map[string]any{ + "email": payload["email"], + "password": payload["password"], + "balance": payload["balance"], + "commission_type": payload["commission_type"], + "commission_rate": payload["commission_rate"], + "commission_balance": payload["commission_balance"], + "group_id": payload["group_id"], + "plan_id": payload["plan_id"], + "speed_limit": payload["speed_limit"], + "device_limit": payload["device_limit"], + "expired_at": payload["expired_at"], + "remarks": payload["remarks"], + "updated_at": now, + } + + // Remove nil values to avoid overwriting with defaults if not provided + for k, v := range values { + if v == nil { + delete(values, k) + } + } + + if err := database.DB.Model(&model.User{}).Where("id = ?", id).Updates(values).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to update user") + return + } + Success(c, true) +} + +func AdminUsersFetch(c *gin.Context) { + page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) + perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50) + keyword := strings.TrimSpace(c.Query("keyword")) + + query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC") + if keyword != "" { + query = query.Where("email LIKE ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to count users") + return + } + + var users []model.User + if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch users") + return + } + + groupNames := loadServerGroupNameMap() + items := make([]gin.H, 0, len(users)) + for _, user := range users { + items = append(items, gin.H{ + "id": user.ID, + "email": user.Email, + "balance": user.Balance, + "group_id": intValue(user.GroupID), + "group_name": groupNames[intFromPointer(user.GroupID)], + "plan_id": intValue(user.PlanID), + "plan_name": planName(user.Plan), + "transfer_enable": user.TransferEnable, + "u": user.U, + "d": user.D, + "banned": user.Banned, + "is_admin": user.IsAdmin, + "is_staff": user.IsStaff, + "device_limit": intValue(user.DeviceLimit), + "online_count": intValue(user.OnlineCount), + "expired_at": int64Value(user.ExpiredAt), + "last_login_at": int64Value(user.LastLoginAt), + "last_online_at": unixTimeValue(user.LastOnlineAt), + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + "remarks": stringValue(user.Remarks), + "commission_type": user.CommissionType, + "commission_rate": intValue(user.CommissionRate), + "commission_balance": user.CommissionBalance, + }) + } + + Success(c, gin.H{ + "list": items, + "filters": gin.H{ + "keyword": keyword, + }, + "pagination": gin.H{ + "current": page, + "last_page": calculateLastPage(total, perPage), + "per_page": perPage, + "total": total, + }, + }) +} + +func AdminTicketsFetch(c *gin.Context) { + if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" { + adminTicketDetail(c, ticketID) + return + } + + page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) + perPage := parsePositiveInt(c.DefaultQuery("per_page", "50"), 50) + keyword := strings.TrimSpace(c.Query("keyword")) + + query := database.DB.Model(&model.Ticket{}).Order("updated_at DESC, id DESC") + if keyword != "" { + query = query.Where("subject LIKE ? OR CAST(user_id AS CHAR) = ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword, keyword) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to count tickets") + return + } + + var tickets []model.Ticket + if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&tickets).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch tickets") + return + } + + userEmails := loadUserEmailMap(extractTicketUserIDs(tickets)) + messageCounts := loadTicketMessageCountMap(extractTicketIDs(tickets)) + items := make([]gin.H, 0, len(tickets)) + for _, ticket := range tickets { + items = append(items, gin.H{ + "id": ticket.ID, + "user_id": ticket.UserID, + "user_email": userEmails[ticket.UserID], + "subject": ticket.Subject, + "level": ticket.Level, + "status": ticket.Status, + "reply_status": ticket.ReplyStatus, + "message_count": messageCounts[ticket.ID], + "created_at": ticket.CreatedAt, + "updated_at": ticket.UpdatedAt, + }) + } + + Success(c, gin.H{ + "list": items, + "filters": gin.H{ + "keyword": keyword, + }, + "pagination": gin.H{ + "current": page, + "last_page": calculateLastPage(total, perPage), + "per_page": perPage, + "total": total, + }, + }) +} + +func AdminGiftCardStatus(c *gin.Context) { + Success(c, gin.H{ + "supported": false, + "status": "not_integrated", + "message": "Gift card management is not fully integrated in the current Go backend yet.", + }) +} + +func adminTicketDetail(c *gin.Context, rawID string) { + var ticket model.Ticket + if err := database.DB.Where("id = ?", rawID).First(&ticket).Error; err != nil { + Fail(c, http.StatusNotFound, "ticket not found") + return + } + + var messages []model.TicketMessage + if err := database.DB.Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error; err != nil { + Fail(c, http.StatusInternalServerError, "failed to fetch ticket messages") + return + } + + userEmails := loadUserEmailMap([]int{ticket.UserID}) + Success(c, gin.H{ + "id": ticket.ID, + "user_id": ticket.UserID, + "user_email": userEmails[ticket.UserID], + "subject": ticket.Subject, + "level": ticket.Level, + "status": ticket.Status, + "reply_status": ticket.ReplyStatus, + "created_at": ticket.CreatedAt, + "updated_at": ticket.UpdatedAt, + "messages": buildTicketMessages(messages, 0), + }) +} + +func extractOrderUserIDs(orders []model.Order) []int { + ids := make([]int, 0, len(orders)) + for _, order := range orders { + ids = append(ids, order.UserID) + } + return ids +} + +func extractTicketUserIDs(tickets []model.Ticket) []int { + ids := make([]int, 0, len(tickets)) + for _, ticket := range tickets { + ids = append(ids, ticket.UserID) + } + return ids +} + +func extractTicketIDs(tickets []model.Ticket) []int { + ids := make([]int, 0, len(tickets)) + for _, ticket := range tickets { + ids = append(ids, ticket.ID) + } + return ids +} + +func intFromPointer(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func planName(plan *model.Plan) string { + if plan == nil { + return "" + } + return plan.Name +} diff --git a/internal/handler/admin_server_api.go b/internal/handler/admin_server_api.go index bb6f098..70bd0f7 100644 --- a/internal/handler/admin_server_api.go +++ b/internal/handler/admin_server_api.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "net/http" - "sort" "strconv" "strings" "time" @@ -771,96 +770,6 @@ func decodeStringSlice(raw *string) []string { return filterNonEmptyStrings(strings.Split(*raw, ",")) } -func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) { - if value == nil { - if fallbackEmptyArray { - empty := "[]" - return &empty, nil - } - return nil, nil - } - - if text, ok := value.(string); ok { - text = strings.TrimSpace(text) - if text == "" { - if fallbackEmptyArray { - empty := "[]" - return &empty, nil - } - return nil, nil - } - if json.Valid([]byte(text)) { - return &text, nil - } - } - - payload, err := json.Marshal(value) - if err != nil { - return nil, err - } - text := string(payload) - return &text, nil -} - -func stringFromAny(value any) string { - switch typed := value.(type) { - case string: - return typed - case json.Number: - return typed.String() - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(typed), 'f', -1, 32) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - case bool: - if typed { - return "true" - } - return "false" - default: - return "" - } -} - -func nullableString(value any) any { - text := strings.TrimSpace(stringFromAny(value)) - if text == "" { - return nil - } - return text -} - -func intFromAny(value any) int { - switch typed := value.(type) { - case int: - return typed - case int8: - return int(typed) - case int16: - return int(typed) - case int32: - return int(typed) - case int64: - return int(typed) - case float32: - return int(typed) - case float64: - return int(typed) - case json.Number: - parsed, _ := typed.Int64() - return int(parsed) - case string: - parsed, _ := strconv.Atoi(strings.TrimSpace(typed)) - return parsed - default: - return 0 - } -} - func float32FromAny(value any) (float32, bool) { switch typed := value.(type) { case float32: @@ -891,64 +800,19 @@ func nullableInt(value any) any { } func nullableInt64(value any) any { - switch typed := value.(type) { - case int64: - if typed > 0 { - return typed - } - case int: - if typed > 0 { - return int64(typed) - } - case float64: - if typed > 0 { - return int64(typed) - } - case string: - if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 { - return parsed - } - } - return nil -} - -func boolFromAny(value any) bool { - switch typed := value.(type) { - case bool: - return typed - case int: - return typed != 0 - case int64: - return typed != 0 - case float64: - return typed != 0 - case string: - text := strings.TrimSpace(strings.ToLower(typed)) - return text == "1" || text == "true" || text == "yes" || text == "on" - default: - return false - } -} - -func stringValue(value *string) string { - if value == nil { - return "" - } - return *value -} - -func intValue(value *int) any { - if value == nil { + parsed := intFromAny(value) + if parsed <= 0 { return nil } - return *value + return int64(parsed) } -func int64Value(value *int64) any { - if value == nil { +func nullableString(value any) any { + text := strings.TrimSpace(stringFromAny(value)) + if text == "" { return nil } - return *value + return text } func filterNonEmptyStrings(values any) []string { @@ -972,23 +836,6 @@ func filterNonEmptyStrings(values any) []string { return result } -func sanitizePositiveIDs(ids []int) []int { - unique := make(map[int]struct{}, len(ids)) - result := make([]int, 0, len(ids)) - for _, id := range ids { - if id <= 0 { - continue - } - if _, exists := unique[id]; exists { - continue - } - unique[id] = struct{}{} - result = append(result, id) - } - sort.Ints(result) - return result -} - func intSliceContains(values []int, target int) bool { for _, value := range values { if value == target { @@ -1007,9 +854,3 @@ func isAllowedRouteAction(action string) bool { } } -func unixTimeValue(value *time.Time) any { - if value == nil { - return nil - } - return value.Unix() -} diff --git a/internal/handler/common.go b/internal/handler/common.go index 6cdf30e..efccceb 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -1,7 +1,14 @@ package handler import ( + "encoding/json" "net/http" + "sort" + "strconv" + "strings" + "time" + "xboard-go/internal/database" + "xboard-go/internal/model" "github.com/gin-gonic/gin" ) @@ -29,3 +36,201 @@ func NotImplemented(endpoint string) gin.HandlerFunc { }) } } + +func intFromAny(value any) int { + switch typed := value.(type) { + case int: + return typed + case int8: + return int(typed) + case int16: + return int(typed) + case int32: + return int(typed) + case int64: + return int(typed) + case float32: + return int(typed) + case float64: + return int(typed) + case json.Number: + parsed, _ := typed.Int64() + return int(parsed) + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(typed)) + return parsed + default: + return 0 + } +} + +func stringFromAny(value any) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 32) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + case bool: + if typed { + return "true" + } + return "false" + default: + return "" + } +} + +func boolFromAny(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case int: + return typed != 0 + case int64: + return typed != 0 + case float64: + return typed != 0 + case string: + text := strings.TrimSpace(strings.ToLower(typed)) + return text == "1" || text == "true" || text == "yes" || text == "on" + default: + return false + } +} + +func intValue(value *int) any { + if value == nil { + return nil + } + return *value +} + +func int64Value(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func unixTimeValue(value *time.Time) any { + if value == nil { + return nil + } + return value.Unix() +} + +func marshalJSON(value any, fallbackEmptyArray bool) (*string, error) { + if value == nil { + if fallbackEmptyArray { + empty := "[]" + return &empty, nil + } + return nil, nil + } + + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + if text == "" { + if fallbackEmptyArray { + empty := "[]" + return &empty, nil + } + return nil, nil + } + if json.Valid([]byte(text)) { + return &text, nil + } + } + + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + text := string(payload) + return &text, nil +} + +func sanitizePositiveIDs(ids []int) []int { + unique := make(map[int]struct{}, len(ids)) + result := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + result = append(result, id) + } + sort.Ints(result) + return result +} + +func loadServerGroupNameMap() map[int]string { + var groups []model.ServerGroup + _ = database.DB.Find(&groups).Error + + result := make(map[int]string, len(groups)) + for _, group := range groups { + result[group.ID] = group.Name + } + return result +} + +func loadUserEmailMap(userIDs []int) map[int]string { + result := make(map[int]string) + if len(userIDs) == 0 { + return result + } + + var users []model.User + if err := database.DB.Select("id", "email").Where("id IN ?", sanitizePositiveIDs(userIDs)).Find(&users).Error; err != nil { + return result + } + + for _, user := range users { + result[user.ID] = user.Email + } + return result +} + +func loadTicketMessageCountMap(ticketIDs []int) map[int]int64 { + result := make(map[int]int64) + if len(ticketIDs) == 0 { + return result + } + + type ticketCount struct { + TicketID int + Total int64 + } + var counts []ticketCount + if err := database.DB.Model(&model.TicketMessage{}). + Select("ticket_id, COUNT(*) AS total"). + Where("ticket_id IN ?", sanitizePositiveIDs(ticketIDs)). + Group("ticket_id"). + Scan(&counts).Error; err != nil { + return result + } + + for _, item := range counts { + result[item.TicketID] = item.Total + } + return result +} diff --git a/internal/handler/plugin_api.go b/internal/handler/plugin_api.go index 7c52bbc..a066375 100644 --- a/internal/handler/plugin_api.go +++ b/internal/handler/plugin_api.go @@ -12,10 +12,6 @@ import ( ) func PluginUserOnlineDevicesUsers(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginUserOnlineDevices) { - Fail(c, 400, "plugin is not enabled") - return - } page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20) @@ -92,10 +88,6 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) { } func PluginUserOnlineDevicesGetIP(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginUserOnlineDevices) { - Fail(c, 400, "plugin is not enabled") - return - } user, ok := currentUser(c) if !ok { @@ -138,10 +130,6 @@ func PluginUserOnlineDevicesGetIP(c *gin.Context) { } func PluginUserAddIPv6Check(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginUserAddIPv6) { - Fail(c, 400, "plugin is not enabled") - return - } user, ok := currentUser(c) if !ok { @@ -171,10 +159,6 @@ func PluginUserAddIPv6Check(c *gin.Context) { } func PluginUserAddIPv6Enable(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginUserAddIPv6) { - Fail(c, 400, "plugin is not enabled") - return - } user, ok := currentUser(c) if !ok { @@ -191,10 +175,6 @@ func PluginUserAddIPv6Enable(c *gin.Context) { } func PluginUserAddIPv6SyncPassword(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginUserAddIPv6) { - Fail(c, 400, "plugin is not enabled") - return - } user, ok := currentUser(c) if !ok { @@ -234,41 +214,6 @@ func AdminSystemStatus(c *gin.Context) { }) } -func AdminPluginsList(c *gin.Context) { - var plugins []model.Plugin - if err := database.DB.Order("id ASC").Find(&plugins).Error; err != nil { - Fail(c, 500, "failed to fetch plugins") - return - } - Success(c, plugins) -} - -func AdminPluginTypes(c *gin.Context) { - Success(c, []string{"feature", "payment"}) -} - -func AdminPluginIntegrationStatus(c *gin.Context) { - Success(c, gin.H{ - "user_online_devices": gin.H{ - "enabled": service.IsPluginEnabled(service.PluginUserOnlineDevices), - "status": "complete", - "summary": "用户侧在线设备概览与后台设备监控已接入 Go 后端。", - "endpoints": []string{"/api/v1/user/user-online-devices/get-ip", "/api/v1/" + service.GetAdminSecurePath() + "/user-online-devices/users"}, - }, - "real_name_verification": gin.H{ - "enabled": service.IsPluginEnabled(service.PluginRealNameVerification), - "status": "complete", - "summary": "实名状态查询、提交、后台审核与批量同步均已整合,并补齐 auto_approve/allow_resubmit_after_reject 行为。", - "endpoints": []string{"/api/v1/user/real-name-verification/status", "/api/v1/user/real-name-verification/submit", "/api/v1/" + service.GetAdminSecurePath() + "/realname/records"}, - }, - "user_add_ipv6_subscription": gin.H{ - "enabled": service.IsPluginEnabled(service.PluginUserAddIPv6), - "status": "integrated_with_runtime_sync", - "summary": "用户启用、密码同步与运行时影子账号同步已接入;订单生命周期自动钩子仍依赖后续完整订单流重构。", - "endpoints": []string{"/api/v1/user/user-add-ipv6-subscription/check", "/api/v1/user/user-add-ipv6-subscription/enable", "/api/v1/user/user-add-ipv6-subscription/sync-password"}, - }, - }) -} func parsePositiveInt(raw string, defaultValue int) int { value, err := strconv.Atoi(strings.TrimSpace(raw)) diff --git a/internal/handler/realname_api.go b/internal/handler/realname_api.go index 6c32a63..4c7e1ed 100644 --- a/internal/handler/realname_api.go +++ b/internal/handler/realname_api.go @@ -14,11 +14,6 @@ import ( const unverifiedExpiration = int64(946684800) func PluginRealNameStatus(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } - user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") @@ -54,11 +49,6 @@ func PluginRealNameStatus(c *gin.Context) { } func PluginRealNameSubmit(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } - user, ok := currentUser(c) if !ok { Fail(c, 401, "unauthorized") @@ -126,11 +116,6 @@ func PluginRealNameSubmit(c *gin.Context) { } func PluginRealNameRecords(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } - page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20) keyword := strings.TrimSpace(c.Query("keyword")) @@ -192,11 +177,6 @@ func PluginRealNameRecords(c *gin.Context) { } func PluginRealNameReview(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } - userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { Fail(c, 400, "invalid user id") @@ -239,11 +219,6 @@ func PluginRealNameReview(c *gin.Context) { } func PluginRealNameReset(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } - userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { Fail(c, 400, "invalid user id") @@ -256,18 +231,10 @@ func PluginRealNameReset(c *gin.Context) { } func PluginRealNameSyncAll(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } SuccessMessage(c, "sync completed", performGlobalRealNameSync()) } func PluginRealNameApproveAll(c *gin.Context) { - if !service.IsPluginEnabled(service.PluginRealNameVerification) { - Fail(c, 400, "plugin is not enabled") - return - } var users []model.User database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users) diff --git a/internal/handler/web_pages.go b/internal/handler/web_pages.go index 9ca4a01..47ae6c4 100644 --- a/internal/handler/web_pages.go +++ b/internal/handler/web_pages.go @@ -63,12 +63,19 @@ func AdminAppPage(c *gin.Context) { "title": service.MustGetString("app_name", "XBoard") + " Admin", "securePath": securePath, "api": map[string]string{ - "adminConfig": "/api/v2/" + securePath + "/config/fetch", - "systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus", - "plugins": "/api/v2/" + securePath + "/plugin/getPlugins", - "integration": "/api/v2/" + securePath + "/plugin/integration-status", - "realnameBase": "/api/v1/" + securePath + "/realname", - "onlineDevices": "/api/v1/" + securePath + "/user-online-devices/users", + "adminConfig": "/api/v2/" + securePath + "/config/fetch", + "saveConfig": "/api/v2/" + securePath + "/config/save", + "dashboardSummary": "/api/v2/" + securePath + "/dashboard/summary", + "systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus", + "serverNodeSort": "/api/v2/" + securePath + "/server/manage/sort", + "plans": "/api/v2/" + securePath + "/plan/fetch", + "orders": "/api/v2/" + securePath + "/order/fetch", + "coupons": "/api/v2/" + securePath + "/coupon/fetch", + "users": "/api/v2/" + securePath + "/user/fetch", + "tickets": "/api/v2/" + securePath + "/ticket/fetch", + "giftCardStatus": "/api/v2/" + securePath + "/gift-card/status", + "realnameBase": "/api/v2/" + securePath + "/realname", + "onlineDevices": "/api/v2/" + securePath + "/user-online-devices/users", }, } payload := adminAppViewData{