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 = '
× ';
+ if (m.type === "plan") html += renderPlanForm(m.data);
+ if (m.type === "user") html += renderUserForm(m.data);
+ if (m.type === "coupon") html += renderCouponForm(m.data);
+ html += '
';
+ return html;
+ }
+
+ function renderPlanForm(d) {
+ return [
+ '' + (d.id ? "Edit Plan" : "Create Plan") + ' ',
+ ''
+ ].join("");
+ }
+
+ function renderUserForm(d) {
+ return [
+ 'Edit User ',
+ '',
+ ' ',
+ 'Email Address
',
+ 'New Password (Optional)
',
+ 'Balance (CNY Cents)
',
+ 'Subscription None ' + state.plans.map(p => ''+p.name+' ').join("") + '
',
+ 'Expiry Date
',
+ 'Update User ',
+ ' '
+ ].join("");
+ }
+
+ function renderCouponForm(d) {
+ return [
+ 'Create Coupon ',
+ '',
+ 'Name
',
+ 'Code
',
+ 'Type Fixed Amount Percentage
',
+ 'Value
',
+ 'Save Coupon ',
+ ' '
+ ].join("");
}
function renderLogin() {
return [
'',
- '
',
- '' + escapeHtml(cfg.title || "Admin") + ' Use an administrator account to sign in and open the rebuilt backend workspace.
',
+ '',
+ '',
+ '
SingBox Gopanel ',
+ '
Welcome Back ',
+ '
Sign in to manage your high-performance network.
',
+ '
',
state.message ? renderNotice() : "",
'',
- 'Email
',
- 'Password
',
- 'Sign In ',
+ 'Email Address
',
+ 'Secure Password
',
+ 'Unlock Console ',
' ',
- 'The backend now uses a single Go-rendered admin shell with integrated plugin pages.
',
' ',
' '
].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) + ' ',
'
',
'
',
- 'Refresh Logout
',
+ '',
+ 'Sync State ',
+ '
',
' '
].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 [
+ '
New Package
',
+ '
',
+ 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"),
+ '
Edit Delete
'
+ ];
+ })),
+ '
'
+ ].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 ? 'Approve Cancel ' : '-') + '
'
+ ];
+ })),
+ '
'
+ ].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)),
+ '
Edit Reset ' + (row.banned ? 'Unban ' : 'Ban ') + '
'
+ ];
+ })),
+ '
',
+ renderPagination(state.users.pagination)
+ ].join("");
+ }
+
+ function renderCouponManage() {
+ var rows = state.coupons || [];
+ return [
+ '
Generate Coupon
',
+ '
',
+ 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 + "%"),
+ '
Delete
'
+ ];
+ })),
+ '
'
+ ].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 [
+ '
Add New Node
',
+ '
',
+ 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"),
+ '
Edit Copy
'
+ ];
+ })),
+ '
'
].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.
',
- 'Approve All Sync All Clear Cache
',
- 'ID Email Status Real Name ID Number Submitted At Actions ',
- loading ? 'Loading verification records... ' : rows.length ? rows.map(function (row) {
+ 'Approve All Pending Global Sync
',
+ '',
+ renderTable(["User ID", "Email", "Status", "Full Name", "Identity No", "Actions"], rows.map(function (row) {
return [
- '
',
- '' + escapeHtml(String(row.id || "")) + ' ',
- '' + escapeHtml(row.email || "-") + ' ',
- '' + renderStatus(row.status) + ' ',
- '' + escapeHtml(row.real_name || "-") + ' ',
- '' + escapeHtml(row.identity_no_masked || "-") + ' ',
- '' + escapeHtml(formatDate(row.submitted_at)) + ' ',
- 'Approve Reject Reset
',
- ' '
- ].join("");
- }).join("") : '
No verification records available. ',
- '
',
- ' '
+ '
' + row.id + '',
+ escapeHtml(row.email),
+ renderStatus(row.status),
+ '
' + escapeHtml(row.real_name) + ' ',
+ '
' + escapeHtml(row.identity_no_masked) + '',
+ '
Approve Reject
'
+ ];
+ })),
+ '
'
].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.
',
- 'User Subscription Online Count Online IPs Last Seen Created At ',
- loading ? 'Loading device records... ' : rows.length ? rows.map(function (row) {
+ '',
+ renderTable(["User Email", "Subscription", "IPs", "Last Seen"], rows.map(function (row) {
return [
- '
',
- '' + 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 || "-") + ' ',
- ' '
- ].join("");
- }).join("") : '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 '';
- }
-
- function renderPluginsTable() {
- var rows = toArray(state.plugins);
- return 'ID Code Status Config ' + (rows.length ? rows.map(function (row) {
- return '' + escapeHtml(String(row.id || "")) + ' ' + escapeHtml(row.code || row.name || "-") + ' ' + renderStatus(row.is_enabled ? "enabled" : "disabled") + ' ' + escapeHtml(formatPluginConfig(row.config)) + ' ';
- }).join("") : '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 '' + escapeHtml(h) + ' '; }).join(""),
+ ' ',
+ rows.map(function(row) { return '' + row.map(function(c) { return '' + c + ' '; }).join("") + ' '; }).join(""),
+ '
'
+ ].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 '';
+ var active = state.route === route ? "active" : "";
+ return '';
}
- 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('' + i + ' ');
+ }
+ return '';
}
- 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{