基本功能已初步完善
This commit is contained in:
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker.gitea.com/runner-images:ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -134,10 +134,6 @@ func main() {
|
|||||||
admin.POST("/order/assign", handler.AdminOrderAssign)
|
admin.POST("/order/assign", handler.AdminOrderAssign)
|
||||||
admin.POST("/order/update", handler.AdminOrderUpdate)
|
admin.POST("/order/update", handler.AdminOrderUpdate)
|
||||||
|
|
||||||
admin.POST("/coupon/fetch", handler.AdminCouponsFetch)
|
|
||||||
admin.POST("/coupon/save", handler.AdminCouponSave)
|
|
||||||
admin.POST("/coupon/drop", handler.AdminCouponDrop)
|
|
||||||
|
|
||||||
admin.POST("/ticket/fetch", handler.AdminTicketsFetch)
|
admin.POST("/ticket/fetch", handler.AdminTicketsFetch)
|
||||||
|
|
||||||
// Knowledge Base
|
// Knowledge Base
|
||||||
@@ -149,14 +145,6 @@ func main() {
|
|||||||
knowledgeGrp.POST("/sort", handler.AdminKnowledgeSort)
|
knowledgeGrp.POST("/sort", handler.AdminKnowledgeSort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gift Card
|
|
||||||
giftCardGrp := admin.Group("/gift-card")
|
|
||||||
{
|
|
||||||
giftCardGrp.GET("/fetch", handler.AdminGiftCardFetch)
|
|
||||||
giftCardGrp.POST("/save", handler.AdminGiftCardSave)
|
|
||||||
giftCardGrp.POST("/generate", handler.AdminGiftCardGenerate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traffic Reset
|
// Traffic Reset
|
||||||
trafficResetGrp := admin.Group("/traffic-reset")
|
trafficResetGrp := admin.Group("/traffic-reset")
|
||||||
{
|
{
|
||||||
@@ -190,14 +178,6 @@ func main() {
|
|||||||
admin.POST("/server/route/save", handler.AdminServerRouteSave)
|
admin.POST("/server/route/save", handler.AdminServerRouteSave)
|
||||||
admin.POST("/server/route/drop", handler.AdminServerRouteDrop)
|
admin.POST("/server/route/drop", handler.AdminServerRouteDrop)
|
||||||
|
|
||||||
// Payment
|
|
||||||
admin.GET("/payment/fetch", handler.AdminPaymentFetch)
|
|
||||||
admin.POST("/payment/save", handler.AdminPaymentSave)
|
|
||||||
admin.POST("/payment/drop", handler.AdminPaymentDrop)
|
|
||||||
admin.POST("/payment/show", handler.AdminPaymentShow)
|
|
||||||
admin.POST("/payment/sort", handler.AdminPaymentSort)
|
|
||||||
admin.GET("/payment/getPaymentMethods", handler.AdminGetPaymentMethods)
|
|
||||||
|
|
||||||
// Notice
|
// Notice
|
||||||
admin.GET("/notice/fetch", handler.AdminNoticeFetch)
|
admin.GET("/notice/fetch", handler.AdminNoticeFetch)
|
||||||
admin.POST("/notice/save", handler.AdminNoticeSave)
|
admin.POST("/notice/save", handler.AdminNoticeSave)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"xboard-go/internal/config"
|
"xboard-go/internal/config"
|
||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
"xboard-go/internal/handler"
|
"xboard-go/internal/handler"
|
||||||
@@ -188,12 +189,16 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
|
|||||||
|
|
||||||
admin.GET("/config/fetch", handler.AdminConfigFetch)
|
admin.GET("/config/fetch", handler.AdminConfigFetch)
|
||||||
admin.POST("/config/save", handler.AdminConfigSave)
|
admin.POST("/config/save", handler.AdminConfigSave)
|
||||||
|
admin.GET("/config/getEmailTemplate", handler.AdminGetEmailTemplate)
|
||||||
|
admin.POST("/config/testSendMail", handler.AdminTestSendMail)
|
||||||
|
admin.POST("/config/setTelegramWebhook", handler.AdminSetTelegramWebhook)
|
||||||
admin.GET("/dashboard/summary", handler.AdminDashboardSummary)
|
admin.GET("/dashboard/summary", handler.AdminDashboardSummary)
|
||||||
|
|
||||||
admin.GET("/stat/getStats", handler.AdminDashboardSummary)
|
admin.GET("/stat/getStats", handler.AdminDashboardSummary)
|
||||||
admin.GET("/stat/getOverride", handler.AdminDashboardSummary)
|
admin.GET("/stat/getOverride", handler.AdminDashboardSummary)
|
||||||
admin.GET("/stat/getTrafficRank", handler.AdminGetTrafficRank)
|
admin.GET("/stat/getTrafficRank", handler.AdminGetTrafficRank)
|
||||||
admin.GET("/stat/getOrder", handler.AdminGetOrderStats)
|
admin.GET("/stat/getOrder", handler.AdminGetOrderStats)
|
||||||
|
admin.POST("/stat/getStatUser", handler.AdminGetStatUser)
|
||||||
|
|
||||||
admin.GET("/system/getSystemStatus", handler.AdminSystemStatus)
|
admin.GET("/system/getSystemStatus", handler.AdminSystemStatus)
|
||||||
admin.GET("/system/getQueueStats", handler.AdminSystemQueueStats)
|
admin.GET("/system/getQueueStats", handler.AdminSystemQueueStats)
|
||||||
@@ -222,25 +227,40 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
|
|||||||
admin.POST("/plan/sort", handler.AdminPlanSort)
|
admin.POST("/plan/sort", handler.AdminPlanSort)
|
||||||
|
|
||||||
admin.GET("/order/fetch", handler.AdminOrdersFetch)
|
admin.GET("/order/fetch", handler.AdminOrdersFetch)
|
||||||
|
admin.POST("/order/fetch", handler.AdminOrdersFetch)
|
||||||
|
admin.POST("/order/detail", handler.AdminOrderDetail)
|
||||||
admin.POST("/order/paid", handler.AdminOrderPaid)
|
admin.POST("/order/paid", handler.AdminOrderPaid)
|
||||||
admin.POST("/order/cancel", handler.AdminOrderCancel)
|
admin.POST("/order/cancel", handler.AdminOrderCancel)
|
||||||
|
admin.POST("/order/update", handler.AdminOrderUpdate)
|
||||||
admin.GET("/coupon/fetch", handler.AdminCouponsFetch)
|
admin.POST("/order/assign", handler.AdminOrderAssign)
|
||||||
admin.POST("/coupon/save", handler.AdminCouponSave)
|
|
||||||
admin.POST("/coupon/drop", handler.AdminCouponDrop)
|
|
||||||
|
|
||||||
admin.GET("/user/fetch", handler.AdminUsersFetch)
|
admin.GET("/user/fetch", handler.AdminUsersFetch)
|
||||||
|
admin.POST("/user/fetch", handler.AdminUsersFetch)
|
||||||
admin.POST("/user/update", handler.AdminUserUpdate)
|
admin.POST("/user/update", handler.AdminUserUpdate)
|
||||||
admin.POST("/user/ban", handler.AdminUserBan)
|
admin.POST("/user/ban", handler.AdminUserBan)
|
||||||
|
admin.POST("/user/resetSecret", handler.AdminUserResetSecret)
|
||||||
|
admin.POST("/user/sendMail", handler.AdminUserSendMail)
|
||||||
|
admin.POST("/user/destroy", handler.AdminUserDelete)
|
||||||
admin.POST("/user/resetTraffic", handler.AdminUserResetTraffic)
|
admin.POST("/user/resetTraffic", handler.AdminUserResetTraffic)
|
||||||
admin.POST("/user/drop", handler.AdminUserDelete)
|
admin.POST("/user/drop", handler.AdminUserDelete)
|
||||||
admin.GET("/ticket/fetch", handler.AdminTicketsFetch)
|
admin.GET("/ticket/fetch", handler.AdminTicketsFetch)
|
||||||
admin.GET("/gift-card/status", handler.AdminGiftCardStatus)
|
admin.POST("/ticket/fetch", handler.AdminTicketsFetch)
|
||||||
admin.GET("/traffic-reset/fetch", handler.AdminTrafficResetFetch)
|
admin.GET("/traffic-reset/fetch", handler.AdminTrafficResetFetch)
|
||||||
admin.GET("/traffic-reset/logs", handler.AdminTrafficResetFetch)
|
admin.GET("/traffic-reset/logs", handler.AdminTrafficResetFetch)
|
||||||
admin.POST("/traffic-reset/reset-user", handler.AdminTrafficResetUser)
|
admin.POST("/traffic-reset/reset-user", handler.AdminTrafficResetUser)
|
||||||
admin.GET("/traffic-reset/user/:id/history", handler.AdminTrafficResetUserHistory)
|
admin.GET("/traffic-reset/user/:id/history", handler.AdminTrafficResetUserHistory)
|
||||||
|
|
||||||
|
admin.GET("/notice/fetch", handler.AdminNoticeFetch)
|
||||||
|
admin.POST("/notice/save", handler.AdminNoticeSave)
|
||||||
|
admin.POST("/notice/drop", handler.AdminNoticeDrop)
|
||||||
|
admin.POST("/notice/show", handler.AdminNoticeShow)
|
||||||
|
admin.POST("/notice/sort", handler.AdminNoticeSort)
|
||||||
|
|
||||||
|
admin.GET("/knowledge/fetch", handler.AdminKnowledgeFetch)
|
||||||
|
admin.POST("/knowledge/save", handler.AdminKnowledgeSave)
|
||||||
|
admin.POST("/knowledge/drop", handler.AdminKnowledgeDrop)
|
||||||
|
admin.POST("/knowledge/sort", handler.AdminKnowledgeSort)
|
||||||
|
|
||||||
// Integrated Admin Features
|
// Integrated Admin Features
|
||||||
admin.GET("/realname/records", handler.PluginRealNameRecords)
|
admin.GET("/realname/records", handler.PluginRealNameRecords)
|
||||||
admin.POST("/realname/clear-cache", handler.PluginRealNameClearCache)
|
admin.POST("/realname/clear-cache", handler.PluginRealNameClearCache)
|
||||||
@@ -249,6 +269,9 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
|
|||||||
admin.POST("/realname/sync-all", handler.PluginRealNameSyncAll)
|
admin.POST("/realname/sync-all", handler.PluginRealNameSyncAll)
|
||||||
admin.POST("/realname/approve-all", handler.PluginRealNameApproveAll)
|
admin.POST("/realname/approve-all", handler.PluginRealNameApproveAll)
|
||||||
admin.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers)
|
admin.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers)
|
||||||
|
admin.GET("/user-add-ipv6-subscription/users", handler.AdminIPv6SubscriptionUsers)
|
||||||
|
admin.POST("/user-add-ipv6-subscription/enable/:userId", handler.AdminIPv6SubscriptionEnable)
|
||||||
|
admin.POST("/user-add-ipv6-subscription/sync-password/:userId", handler.AdminIPv6SubscriptionSyncPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerWebRoutes(router *gin.Engine) {
|
func registerWebRoutes(router *gin.Engine) {
|
||||||
@@ -267,5 +290,14 @@ func registerWebRoutes(router *gin.Engine) {
|
|||||||
router.GET("/dashboard", handler.UserThemePage)
|
router.GET("/dashboard", handler.UserThemePage)
|
||||||
router.GET(securePath, handler.AdminAppPage)
|
router.GET(securePath, handler.AdminAppPage)
|
||||||
router.GET(securePath+"/", handler.AdminAppPage)
|
router.GET(securePath+"/", handler.AdminAppPage)
|
||||||
|
router.GET(securePath+"/plugin-panel/:kind", handler.AdminPluginPanelPage)
|
||||||
router.GET(securePath+"/plugins/:plugin", handler.AdminAppPage)
|
router.GET(securePath+"/plugins/:plugin", handler.AdminAppPage)
|
||||||
|
router.NoRoute(func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if path == securePath || strings.HasPrefix(path, securePath+"/") {
|
||||||
|
handler.AdminAppPage(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(404, gin.H{"message": "not found"})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,6 +248,26 @@ tbody tr:hover {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.relation-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fafafa;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-chip code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-arrow {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
token: readToken(),
|
token: readToken(),
|
||||||
user: null,
|
user: null,
|
||||||
route: normalizeRoute(readRoute()),
|
route: "overview",
|
||||||
busy: false,
|
busy: false,
|
||||||
message: "",
|
message: "",
|
||||||
messageType: "info",
|
messageType: "info",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
tickets: { list: [], pagination: null },
|
tickets: { list: [], pagination: null },
|
||||||
realname: { list: [], pagination: null },
|
realname: { list: [], pagination: null },
|
||||||
devices: { list: [], pagination: null },
|
devices: { list: [], pagination: null },
|
||||||
|
ipv6: { list: [], pagination: null },
|
||||||
expandedNodes: new Set()
|
expandedNodes: new Set()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,9 +55,12 @@
|
|||||||
"ticket-manage": { title: "工单中心", description: "查看用户工单和处理状态。" },
|
"ticket-manage": { title: "工单中心", description: "查看用户工单和处理状态。" },
|
||||||
realname: { title: "实名认证", description: "审核实名记录和同步状态。" },
|
realname: { title: "实名认证", description: "审核实名记录和同步状态。" },
|
||||||
"user-online-devices": { title: "在线设备", description: "查看用户在线 IP 和设备分布。" },
|
"user-online-devices": { title: "在线设备", description: "查看用户在线 IP 和设备分布。" },
|
||||||
|
"user-ipv6-subscription": { title: "IPv6 子账号", description: "管理 IPv6 阴影账号与密码同步。" },
|
||||||
"system-config": { title: "系统设置", description: "编辑站点、订阅和安全参数。" }
|
"system-config": { title: "系统设置", description: "编辑站点、订阅和安全参数。" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state.route = normalizeRoute(readRoute());
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
@@ -146,7 +150,7 @@
|
|||||||
state.plans = toArray(unwrap(plans));
|
state.plans = toArray(unwrap(plans));
|
||||||
state.groups = toArray(unwrap(groups));
|
state.groups = toArray(unwrap(groups));
|
||||||
} else if (state.route === "order-manage") {
|
} else if (state.route === "order-manage") {
|
||||||
const payload = unwrap(await request(`${cfg.api.orders}?page=${page}&per_page=20`));
|
const payload = await request(`${cfg.api.orders}?page=${page}&per_page=20`);
|
||||||
state.orders = normalizeListPayload(payload);
|
state.orders = normalizeListPayload(payload);
|
||||||
} else if (state.route === "coupon-manage") {
|
} else if (state.route === "coupon-manage") {
|
||||||
state.coupons = toArray(unwrap(await request(cfg.api.coupons)));
|
state.coupons = toArray(unwrap(await request(cfg.api.coupons)));
|
||||||
@@ -156,18 +160,21 @@
|
|||||||
request(cfg.api.plans),
|
request(cfg.api.plans),
|
||||||
request(cfg.api.serverGroups)
|
request(cfg.api.serverGroups)
|
||||||
]);
|
]);
|
||||||
state.users = normalizeListPayload(unwrap(users));
|
state.users = normalizeListPayload(users);
|
||||||
state.plans = toArray(unwrap(plans));
|
state.plans = toArray(unwrap(plans));
|
||||||
state.groups = toArray(unwrap(groups));
|
state.groups = toArray(unwrap(groups));
|
||||||
} else if (state.route === "ticket-manage") {
|
} else if (state.route === "ticket-manage") {
|
||||||
const payload = unwrap(await request(`${cfg.api.tickets}?page=${page}&per_page=20`));
|
const payload = await request(`${cfg.api.tickets}?page=${page}&per_page=20`);
|
||||||
state.tickets = normalizeListPayload(payload);
|
state.tickets = normalizeListPayload(payload);
|
||||||
} else if (state.route === "realname") {
|
} else if (state.route === "realname") {
|
||||||
const payload = unwrap(await request(`${cfg.api.realnameBase}/records?page=${page}&per_page=20`));
|
const payload = await request(`${cfg.api.realnameBase}/records?page=${page}&per_page=20`);
|
||||||
state.realname = normalizeListPayload(payload, "data");
|
state.realname = normalizeListPayload(payload, "data");
|
||||||
} else if (state.route === "user-online-devices") {
|
} else if (state.route === "user-online-devices") {
|
||||||
const payload = unwrap(await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`));
|
const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`);
|
||||||
state.devices = normalizeListPayload(payload);
|
state.devices = normalizeListPayload(payload);
|
||||||
|
} else if (state.route === "user-ipv6-subscription") {
|
||||||
|
const payload = await request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`);
|
||||||
|
state.ipv6 = normalizeListPayload(payload);
|
||||||
} else if (state.route === "system-config") {
|
} else if (state.route === "system-config") {
|
||||||
state.config = unwrap(await request(cfg.api.adminConfig));
|
state.config = unwrap(await request(cfg.api.adminConfig));
|
||||||
}
|
}
|
||||||
@@ -426,6 +433,21 @@
|
|||||||
if (action === "sync-all") {
|
if (action === "sync-all") {
|
||||||
await adminPost(`${cfg.api.realnameBase}/sync-all`, {});
|
await adminPost(`${cfg.api.realnameBase}/sync-all`, {});
|
||||||
await hydrateRoute();
|
await hydrateRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "ipv6-enable") {
|
||||||
|
const userId = actionEl.getAttribute("data-user-id");
|
||||||
|
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, {});
|
||||||
|
await hydrateRoute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "ipv6-sync-password") {
|
||||||
|
const userId = actionEl.getAttribute("data-user-id");
|
||||||
|
await adminPost(`${cfg.api.ipv6Base}/sync-password/${userId}`, {});
|
||||||
|
await hydrateRoute();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +463,7 @@
|
|||||||
if (action === "login") {
|
if (action === "login") {
|
||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const payload = unwrap(await request("/api/v1/passport/auth/login", {
|
const payload = unwrap(await request("/api/v2/passport/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
auth: false,
|
auth: false,
|
||||||
body: serializeForm(form)
|
body: serializeForm(form)
|
||||||
@@ -571,6 +593,7 @@
|
|||||||
navItem("user-manage", "用户管理", "账号、订阅、流量"),
|
navItem("user-manage", "用户管理", "账号、订阅、流量"),
|
||||||
navItem("realname", "实名认证", "实名审核"),
|
navItem("realname", "实名认证", "实名审核"),
|
||||||
navItem("user-online-devices", "在线设备", "在线 IP 与会话"),
|
navItem("user-online-devices", "在线设备", "在线 IP 与会话"),
|
||||||
|
navItem("user-ipv6-subscription", "IPv6 子账号", "开通与密码同步"),
|
||||||
navItem("ticket-manage", "工单中心", "用户支持")
|
navItem("ticket-manage", "工单中心", "用户支持")
|
||||||
]),
|
]),
|
||||||
renderSidebarGroup("system", "系统", [
|
renderSidebarGroup("system", "系统", [
|
||||||
@@ -643,6 +666,7 @@
|
|||||||
if (state.route === "ticket-manage") return renderTicketManage();
|
if (state.route === "ticket-manage") return renderTicketManage();
|
||||||
if (state.route === "realname") return renderRealnameManage();
|
if (state.route === "realname") return renderRealnameManage();
|
||||||
if (state.route === "user-online-devices") return renderOnlineDevices();
|
if (state.route === "user-online-devices") return renderOnlineDevices();
|
||||||
|
if (state.route === "user-ipv6-subscription") return renderIPv6Manage();
|
||||||
if (state.route === "system-config") return renderSystemConfig();
|
if (state.route === "system-config") return renderSystemConfig();
|
||||||
return renderOverview();
|
return renderOverview();
|
||||||
}
|
}
|
||||||
@@ -719,10 +743,13 @@
|
|||||||
const toggle = hasChildren
|
const toggle = hasChildren
|
||||||
? `<button class="node-toggle" data-action="node-expand" data-id="${node.id}">${expanded ? "−" : "+"}</button>`
|
? `<button class="node-toggle" data-action="node-expand" data-id="${node.id}">${expanded ? "−" : "+"}</button>`
|
||||||
: '<span class="node-toggle node-toggle-placeholder"></span>';
|
: '<span class="node-toggle node-toggle-placeholder"></span>';
|
||||||
|
const relationCell = isChild && node.parent_id
|
||||||
|
? `<span class="relation-chip"><code>${escapeHtml(node.id)}</code><span class="relation-arrow">→</span><code>${escapeHtml(node.parent_id)}</code></span>`
|
||||||
|
: `<code>${node.id}</code>`;
|
||||||
return {
|
return {
|
||||||
className: isChild ? "node-child" : "",
|
className: isChild ? "node-child" : "",
|
||||||
cells: [
|
cells: [
|
||||||
`<code>${node.id}</code>`,
|
relationCell,
|
||||||
[
|
[
|
||||||
'<div class="node-name-block">',
|
'<div class="node-name-block">',
|
||||||
toggle,
|
toggle,
|
||||||
@@ -843,10 +870,13 @@
|
|||||||
|
|
||||||
function renderUserManage() {
|
function renderUserManage() {
|
||||||
const rows = state.users.list.map((user) => [
|
const rows = state.users.list.map((user) => [
|
||||||
`<code>${user.id}</code>`,
|
user.parent_id ? renderRelationChip(user.parent_id, user.id) : `<code>${user.id}</code>`,
|
||||||
[
|
[
|
||||||
`<strong>${escapeHtml(user.email || "-")}</strong>`,
|
`<strong>${escapeHtml(user.email || "-")}</strong>`,
|
||||||
user.online_ip ? `<div class="subtle-text">在线 IP: ${escapeHtml(user.online_ip)}</div>` : ""
|
user.parent_id ? `<div class="subtle-text">${renderRelationChip(user.parent_id, user.id)}</div>` : "",
|
||||||
|
user.online_ip ? `<div class="subtle-text">在线 IP: ${escapeHtml(user.online_ip)}</div>` : "",
|
||||||
|
`<div class="subtle-text">实名: ${escapeHtml(user.realname_label || user.realname_status || "-")}</div>`,
|
||||||
|
user.ipv6_enabled ? `<div class="subtle-text">IPv6: ${renderRelationChip(user.id, user.ipv6_shadow_id)}</div>` : ""
|
||||||
].join(""),
|
].join(""),
|
||||||
renderStatus(user.banned ? "banned" : "active"),
|
renderStatus(user.banned ? "banned" : "active"),
|
||||||
escapeHtml(`${formatTraffic((user.u || 0) + (user.d || 0))} / ${formatTraffic(user.transfer_enable)}`),
|
escapeHtml(`${formatTraffic((user.u || 0) + (user.d || 0))} / ${formatTraffic(user.transfer_enable)}`),
|
||||||
@@ -922,6 +952,28 @@
|
|||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderIPv6Manage() {
|
||||||
|
const rows = state.ipv6.list.map((item) => [
|
||||||
|
renderRelationChip(item.id, item.shadow_user_id || "-"),
|
||||||
|
[
|
||||||
|
`<strong>${escapeHtml(item.email || "-")}</strong>`,
|
||||||
|
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>`
|
||||||
|
].join(""),
|
||||||
|
escapeHtml(item.plan_name || "-"),
|
||||||
|
renderStatus(item.status_label || item.status || "-"),
|
||||||
|
escapeHtml(formatDate(item.updated_at)),
|
||||||
|
renderActionRow([
|
||||||
|
buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`),
|
||||||
|
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
wrapTable(["主从关系", "账号", "套餐", "状态", "更新时间", "操作"], rows),
|
||||||
|
renderPagination(state.ipv6.pagination)
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderSystemConfig() {
|
function renderSystemConfig() {
|
||||||
const sections = ["site", "subscribe", "server", "safe", "invite", "frontend"];
|
const sections = ["site", "subscribe", "server", "safe", "invite", "frontend"];
|
||||||
const activeTab = sections.includes(state.configTab) ? state.configTab : "site";
|
const activeTab = sections.includes(state.configTab) ? state.configTab : "site";
|
||||||
@@ -1163,6 +1215,10 @@
|
|||||||
return `<div class="row-actions">${items.join("")}</div>`;
|
return `<div class="row-actions">${items.join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRelationChip(fromId, toId) {
|
||||||
|
return `<span class="relation-chip"><code>${escapeHtml(fromId)}</code><span class="relation-arrow">→</span><code>${escapeHtml(toId)}</code></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function hiddenField(name, value) {
|
function hiddenField(name, value) {
|
||||||
return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(String(value))}" />`;
|
return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(String(value))}" />`;
|
||||||
}
|
}
|
||||||
@@ -1432,9 +1488,9 @@
|
|||||||
const text = String(status || "-");
|
const text = String(status || "-");
|
||||||
const normalized = text.toLowerCase();
|
const normalized = text.toLowerCase();
|
||||||
let type = "danger";
|
let type = "danger";
|
||||||
if (/(ok|active|visible|approved|paid|answered|online)/.test(normalized)) {
|
if (/(ok|active|visible|approved|paid|answered|online|enabled)/.test(normalized)) {
|
||||||
type = "ok";
|
type = "ok";
|
||||||
} else if (/(pending|warn|unverified)/.test(normalized)) {
|
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
|
||||||
type = "warn";
|
type = "warn";
|
||||||
}
|
}
|
||||||
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;
|
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;
|
||||||
|
|||||||
54
frontend/admin/main.js
Normal file
54
frontend/admin/main.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const settings = window.settings || {};
|
||||||
|
const assetNonce = window.__ADMIN_ASSET_NONCE__ || String(Date.now());
|
||||||
|
const securePath = String(settings.secure_path || "admin").replace(/^\/+/, "");
|
||||||
|
const adminBase = `/api/v2/${securePath}`;
|
||||||
|
|
||||||
|
window.ADMIN_APP_CONFIG = {
|
||||||
|
title: settings.title || "XBoard Admin",
|
||||||
|
version: settings.version || "1.0.0",
|
||||||
|
securePath,
|
||||||
|
baseUrl: settings.base_url || window.location.origin,
|
||||||
|
api: {
|
||||||
|
adminBase,
|
||||||
|
adminConfig: `${adminBase}/config/fetch`,
|
||||||
|
dashboardSummary: `${adminBase}/dashboard/summary`,
|
||||||
|
systemStatus: `${adminBase}/system/getSystemStatus`,
|
||||||
|
serverNodes: `${adminBase}/server/manage/getNodes`,
|
||||||
|
serverGroups: `${adminBase}/server/group/fetch`,
|
||||||
|
serverRoutes: `${adminBase}/server/route/fetch`,
|
||||||
|
plans: `${adminBase}/plan/fetch`,
|
||||||
|
orders: `${adminBase}/order/fetch`,
|
||||||
|
coupons: `${adminBase}/coupon/fetch`,
|
||||||
|
users: `${adminBase}/user/fetch`,
|
||||||
|
tickets: `${adminBase}/ticket/fetch`,
|
||||||
|
realnameBase: `${adminBase}/realname`,
|
||||||
|
onlineDevices: `${adminBase}/user-online-devices/users`,
|
||||||
|
ipv6Base: `${adminBase}/user-add-ipv6-subscription`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.documentElement.dataset.adminExecutionMode = "main-app";
|
||||||
|
|
||||||
|
function showBootError(error) {
|
||||||
|
console.error("Failed to boot admin app", error);
|
||||||
|
const root = document.getElementById("admin-app");
|
||||||
|
if (root) {
|
||||||
|
root.innerHTML =
|
||||||
|
`<div style="padding:24px;font-family:system-ui,sans-serif;color:#b91c1c;">Admin app failed to load.<br>${String(
|
||||||
|
error && error.message ? error.message : error || "Unknown error",
|
||||||
|
)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
if (!event || !event.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showBootError(event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `/admin-assets/app.js?v=${encodeURIComponent(assetNonce)}`;
|
||||||
|
script.defer = true;
|
||||||
|
script.onerror = () => showBootError(new Error("Failed to load /admin-assets/app.js"));
|
||||||
|
document.body.appendChild(script);
|
||||||
@@ -195337,21 +195337,6 @@ const DQe =
|
|||||||
{ path: "subscribe-template", element: Q.jsx(LQe, {}) },
|
{ path: "subscribe-template", element: Q.jsx(LQe, {}) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "payment",
|
|
||||||
lazy: async () => ({
|
|
||||||
Component: (
|
|
||||||
await xp(
|
|
||||||
async () => {
|
|
||||||
const { default: e } = await Promise.resolve().then(() => kXt);
|
|
||||||
return { default: e };
|
|
||||||
},
|
|
||||||
void 0,
|
|
||||||
import.meta.url,
|
|
||||||
)
|
|
||||||
).default,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "plugin",
|
path: "plugin",
|
||||||
lazy: async () => ({
|
lazy: async () => ({
|
||||||
@@ -195499,36 +195484,6 @@ const DQe =
|
|||||||
).default,
|
).default,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "coupon",
|
|
||||||
lazy: async () => ({
|
|
||||||
Component: (
|
|
||||||
await xp(
|
|
||||||
async () => {
|
|
||||||
const { default: e } = await Promise.resolve().then(() => z3t);
|
|
||||||
return { default: e };
|
|
||||||
},
|
|
||||||
void 0,
|
|
||||||
import.meta.url,
|
|
||||||
)
|
|
||||||
).default,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "gift-card",
|
|
||||||
lazy: async () => ({
|
|
||||||
Component: (
|
|
||||||
await xp(
|
|
||||||
async () => {
|
|
||||||
const { default: e } = await Promise.resolve().then(() => d6t);
|
|
||||||
return { default: e };
|
|
||||||
},
|
|
||||||
void 0,
|
|
||||||
import.meta.url,
|
|
||||||
)
|
|
||||||
).default,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195565,6 +195520,18 @@ const DQe =
|
|||||||
).default,
|
).default,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "realname",
|
||||||
|
lazy: async () => ({ Component: codexNativeRealnamePage }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "online-devices",
|
||||||
|
lazy: async () => ({ Component: codexNativeOnlineDevicesPage }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "ipv6-subscription",
|
||||||
|
lazy: async () => ({ Component: codexNativeIPv6Page }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "traffic-reset-logs",
|
path: "traffic-reset-logs",
|
||||||
lazy: async () => ({
|
lazy: async () => ({
|
||||||
@@ -233443,17 +233410,11 @@ var Zst = {
|
|||||||
href: "/config/system",
|
href: "/config/system",
|
||||||
icon: Q.jsx(Kf, { size: 18 }),
|
icon: Q.jsx(Kf, { size: 18 }),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "nav:pluginManagement",
|
|
||||||
label: "",
|
|
||||||
href: "/config/plugin",
|
|
||||||
icon: Q.jsx(Fat, { size: 18 }),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "nav:themeConfig",
|
title: "nav:themeConfig",
|
||||||
label: "",
|
label: "",
|
||||||
href: "/config/theme",
|
href: "/config/theme",
|
||||||
icon: Q.jsx(im, { size: 18 }),
|
icon: Q.jsx(Kf, { size: 18 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "nav:noticeManagement",
|
title: "nav:noticeManagement",
|
||||||
@@ -233461,12 +233422,6 @@ var Zst = {
|
|||||||
href: "/config/notice",
|
href: "/config/notice",
|
||||||
icon: Q.jsx(pm, { size: 18 }),
|
icon: Q.jsx(pm, { size: 18 }),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "nav:paymentConfig",
|
|
||||||
label: "",
|
|
||||||
href: "/config/payment",
|
|
||||||
icon: Q.jsx(tm, { size: 18 }),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "nav:knowledgeManagement",
|
title: "nav:knowledgeManagement",
|
||||||
label: "",
|
label: "",
|
||||||
@@ -233519,18 +233474,6 @@ var Zst = {
|
|||||||
href: "/finance/order",
|
href: "/finance/order",
|
||||||
icon: Q.jsx(tm, { size: 18 }),
|
icon: Q.jsx(tm, { size: 18 }),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "nav:couponManagement",
|
|
||||||
label: "",
|
|
||||||
href: "/finance/coupon",
|
|
||||||
icon: Q.jsx(rm, { size: 18 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "nav:giftCardManagement",
|
|
||||||
label: "",
|
|
||||||
href: "/finance/gift-card",
|
|
||||||
icon: Q.jsx(lm, { size: 18 }),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -233551,6 +233494,24 @@ var Zst = {
|
|||||||
href: "/user/ticket",
|
href: "/user/ticket",
|
||||||
icon: Q.jsx(xm, { size: 18 }),
|
icon: Q.jsx(xm, { size: 18 }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "实名认证",
|
||||||
|
label: "",
|
||||||
|
href: "/user/realname",
|
||||||
|
icon: Q.jsx(xm, { size: 18 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "在线 IP 统计",
|
||||||
|
label: "",
|
||||||
|
href: "/user/online-devices",
|
||||||
|
icon: Q.jsx(xm, { size: 18 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "IPv6 子账号",
|
||||||
|
label: "",
|
||||||
|
href: "/user/ipv6-subscription",
|
||||||
|
icon: Q.jsx(xm, { size: 18 }),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -264036,26 +263997,6 @@ function x$t({ className: e }) {
|
|||||||
return Q.jsxs("div", {
|
return Q.jsxs("div", {
|
||||||
className: Rf("grid gap-4 md:grid-cols-2 lg:grid-cols-4", e),
|
className: Rf("grid gap-4 md:grid-cols-2 lg:grid-cols-4", e),
|
||||||
children: [
|
children: [
|
||||||
Q.jsx(y$t, {
|
|
||||||
title: n("dashboard:stats.todayIncome"),
|
|
||||||
value: DS(i.todayIncome),
|
|
||||||
icon: Q.jsx(Jst, { className: "h-4 w-4 text-emerald-500" }),
|
|
||||||
trend: {
|
|
||||||
value: i.dayIncomeGrowth,
|
|
||||||
label: n("dashboard:stats.vsYesterday"),
|
|
||||||
isPositive: i.dayIncomeGrowth > 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Q.jsx(y$t, {
|
|
||||||
title: n("dashboard:stats.monthlyIncome"),
|
|
||||||
value: DS(i.currentMonthIncome),
|
|
||||||
icon: Q.jsx(rat, { className: "h-4 w-4 text-blue-500" }),
|
|
||||||
trend: {
|
|
||||||
value: i.monthIncomeGrowth,
|
|
||||||
label: n("dashboard:stats.vsLastMonth"),
|
|
||||||
isPositive: i.monthIncomeGrowth > 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Q.jsx(y$t, {
|
Q.jsx(y$t, {
|
||||||
title: n("dashboard:stats.pendingTickets"),
|
title: n("dashboard:stats.pendingTickets"),
|
||||||
value: i.ticketPendingTotal,
|
value: i.ticketPendingTotal,
|
||||||
@@ -264072,38 +264013,6 @@ function x$t({ className: e }) {
|
|||||||
onClick: () => t("/user/ticket"),
|
onClick: () => t("/user/ticket"),
|
||||||
highlight: i.ticketPendingTotal > 0,
|
highlight: i.ticketPendingTotal > 0,
|
||||||
}),
|
}),
|
||||||
Q.jsx(y$t, {
|
|
||||||
title: n("dashboard:stats.pendingCommission"),
|
|
||||||
value: i.commissionPendingTotal,
|
|
||||||
icon: Q.jsx(oat, {
|
|
||||||
className: Rf(
|
|
||||||
"h-4 w-4",
|
|
||||||
i.commissionPendingTotal > 0 ? "text-blue-500" : "text-muted-foreground",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
description:
|
|
||||||
i.commissionPendingTotal > 0
|
|
||||||
? n("dashboard:stats.hasPendingCommission")
|
|
||||||
: n("dashboard:stats.noPendingCommission"),
|
|
||||||
onClick: () => {
|
|
||||||
const e = new URLSearchParams();
|
|
||||||
(e.set("commission_status", c$t.PENDING.toString()),
|
|
||||||
e.set("status", o$t.COMPLETED.toString()),
|
|
||||||
e.set("commission_balance", "gt:0"),
|
|
||||||
t(`/finance/order?${e.toString()}`));
|
|
||||||
},
|
|
||||||
highlight: i.commissionPendingTotal > 0,
|
|
||||||
}),
|
|
||||||
Q.jsx(y$t, {
|
|
||||||
title: n("dashboard:stats.monthlyNewUsers"),
|
|
||||||
value: i.currentMonthNewUsers,
|
|
||||||
icon: Q.jsx(dlt, { className: "h-4 w-4 text-blue-500" }),
|
|
||||||
trend: {
|
|
||||||
value: i.userGrowth,
|
|
||||||
label: n("dashboard:stats.vsLastMonth"),
|
|
||||||
isPositive: i.userGrowth > 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Q.jsx(y$t, {
|
Q.jsx(y$t, {
|
||||||
title: n("dashboard:stats.totalUsers"),
|
title: n("dashboard:stats.totalUsers"),
|
||||||
value: i.totalUsers,
|
value: i.totalUsers,
|
||||||
@@ -269438,7 +269347,7 @@ const tGt = Object.freeze(
|
|||||||
className: "space-y-6",
|
className: "space-y-6",
|
||||||
children: Q.jsxs("div", {
|
children: Q.jsxs("div", {
|
||||||
className: "grid gap-6",
|
className: "grid gap-6",
|
||||||
children: [Q.jsx(x$t, {}), Q.jsx(t$t, {}), Q.jsx(dqt, {}), Q.jsx(eGt, {})],
|
children: [Q.jsx(x$t, {}), Q.jsx(dqt, {}), Q.jsx(eGt, {})],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -293836,23 +293745,27 @@ const I5t = {
|
|||||||
Q.jsxs("span", {
|
Q.jsxs("span", {
|
||||||
className: "flex flex-col items-start gap-0.5 leading-tight",
|
className: "flex flex-col items-start gap-0.5 leading-tight",
|
||||||
children: [
|
children: [
|
||||||
Q.jsx("span", { className: "flex items-center gap-0.5", children: i ?? n }),
|
e.parent
|
||||||
|
? Q.jsxs("span", {
|
||||||
|
className: "flex items-center gap-1",
|
||||||
|
children: [
|
||||||
|
Q.jsx("span", { children: n }),
|
||||||
|
Q.jsx("span", {
|
||||||
|
className: "text-sm text-muted-foreground/30",
|
||||||
|
children: "\u2192",
|
||||||
|
}),
|
||||||
|
Q.jsx("span", { children: e.parent_id ?? e.parent?.id }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: Q.jsx("span", {
|
||||||
|
className: "flex items-center gap-0.5",
|
||||||
|
children: n,
|
||||||
|
}),
|
||||||
i &&
|
i &&
|
||||||
Q.jsxs("span", {
|
Q.jsxs("span", {
|
||||||
className: "font-mono text-[10px] text-muted-foreground",
|
className: "font-mono text-[10px] text-muted-foreground",
|
||||||
children: [t("columns.originalId"), ": ", n],
|
children: [t("columns.customId"), ": ", i],
|
||||||
}),
|
}),
|
||||||
e.parent
|
|
||||||
? Q.jsxs(Q.Fragment, {
|
|
||||||
children: [
|
|
||||||
Q.jsx("span", {
|
|
||||||
className: "text-sm text-muted-foreground/30",
|
|
||||||
children: "→",
|
|
||||||
}),
|
|
||||||
Q.jsx("span", { children: e.parent?.code || e.parent?.id }),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -304571,6 +304484,496 @@ function l8t() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function codexNativePageLayout(e, t, n) {
|
||||||
|
return Q.jsxs(Wot, {
|
||||||
|
children: [
|
||||||
|
Q.jsxs(Hot, {
|
||||||
|
children: [
|
||||||
|
Q.jsx(Vdt, {}),
|
||||||
|
Q.jsxs("div", {
|
||||||
|
className: "ml-auto flex items-center space-x-4",
|
||||||
|
children: [Q.jsx(Wdt, {}), Q.jsx(vut, {})],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
Q.jsxs(zot, {
|
||||||
|
className: "flex flex-col",
|
||||||
|
fixedHeight: !0,
|
||||||
|
children: [
|
||||||
|
Q.jsx("div", {
|
||||||
|
className: "mb-2 flex items-center justify-between space-y-2",
|
||||||
|
children: Q.jsxs("div", {
|
||||||
|
children: [
|
||||||
|
Q.jsx("h2", { className: "text-2xl font-bold tracking-tight", children: e }),
|
||||||
|
Q.jsx("p", { className: "mt-2 text-muted-foreground", children: t }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Q.jsx("div", {
|
||||||
|
className: "-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0",
|
||||||
|
children: Q.jsx("div", { className: "w-full", children: n }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeStatusBadge(e) {
|
||||||
|
const t = String(e || "-").toLowerCase(),
|
||||||
|
n = /approved|active|enabled|ready/.test(t)
|
||||||
|
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: /pending|eligible|unverified/.test(t)
|
||||||
|
? "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400"
|
||||||
|
: "bg-destructive/10 text-destructive";
|
||||||
|
return Q.jsx("span", {
|
||||||
|
className: Rf("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold", n),
|
||||||
|
children: e || "-",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeRelationChip(e, t) {
|
||||||
|
return Q.jsxs("span", {
|
||||||
|
className:
|
||||||
|
"inline-flex items-center gap-1 rounded-full border bg-muted/50 px-2.5 py-1 font-mono text-xs",
|
||||||
|
children: [
|
||||||
|
e,
|
||||||
|
Q.jsx("span", { className: "text-muted-foreground/60", children: "\u2192" }),
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeSearchToolbar({
|
||||||
|
keyword: e,
|
||||||
|
setKeyword: t,
|
||||||
|
onSearch: n,
|
||||||
|
onReset: i,
|
||||||
|
refetch: r,
|
||||||
|
actions: o,
|
||||||
|
}) {
|
||||||
|
return Q.jsxs("div", {
|
||||||
|
className: "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between",
|
||||||
|
children: [
|
||||||
|
Q.jsxs("div", {
|
||||||
|
className: "flex flex-1 flex-wrap items-center gap-2 sm:flex-nowrap",
|
||||||
|
children: [
|
||||||
|
Q.jsx(Q6e, {
|
||||||
|
placeholder: "搜索用户 ID / 邮箱",
|
||||||
|
value: e,
|
||||||
|
onChange: (e) => t(e.target.value),
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
"Enter" === e.key && n();
|
||||||
|
},
|
||||||
|
className: "h-8 w-full min-w-[150px] sm:w-[180px] lg:w-[280px]",
|
||||||
|
}),
|
||||||
|
Q.jsx(Nm, { variant: "outline", className: "h-8 px-3", onClick: n, children: "搜索" }),
|
||||||
|
Q.jsx(Nm, { variant: "ghost", className: "h-8 px-3", onClick: i, children: "重置" }),
|
||||||
|
Q.jsx(Nm, { variant: "ghost", className: "h-8 px-3", onClick: () => r(), children: "刷新" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
o ? Q.jsx("div", { className: "flex flex-wrap items-center gap-2", children: o }) : null,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeRealnameTable() {
|
||||||
|
const [e, t] = H.useState(""),
|
||||||
|
[n, i] = H.useState(""),
|
||||||
|
[r, o] = H.useState({}),
|
||||||
|
[s, a] = H.useState({}),
|
||||||
|
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
|
||||||
|
{
|
||||||
|
refetch: d,
|
||||||
|
data: u,
|
||||||
|
isLoading: h,
|
||||||
|
} = gC({
|
||||||
|
queryKey: ["codexNativeRealname", l, n],
|
||||||
|
queryFn: () =>
|
||||||
|
TL(`${RL()}/realname/records`, {
|
||||||
|
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
g = H.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: () => "用户 ID",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "font-mono text-sm font-medium", children: e.getValue("id") }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: () => "邮箱",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "real_name",
|
||||||
|
header: () => "姓名",
|
||||||
|
cell: ({ row: e }) => e.getValue("real_name") || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "identity_no_masked",
|
||||||
|
header: () => "证件号",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "font-mono text-xs", children: e.getValue("identity_no_masked") || "-" }),
|
||||||
|
},
|
||||||
|
JKt.display({
|
||||||
|
id: "status",
|
||||||
|
header: () => "状态",
|
||||||
|
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
|
||||||
|
}),
|
||||||
|
JKt.display({
|
||||||
|
id: "actions",
|
||||||
|
header: () => "操作",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsxs("div", {
|
||||||
|
className: "flex flex-wrap items-center gap-2",
|
||||||
|
children: [
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
size: "sm",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/realname/review/${e.original.id}`, { status: "approved", reason: "" });
|
||||||
|
hN.success("已通过实名认证");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "通过",
|
||||||
|
}),
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
size: "sm",
|
||||||
|
variant: "outline",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
const t = window.prompt("请输入驳回原因", "") || "";
|
||||||
|
await IL(`${RL()}/realname/review/${e.original.id}`, {
|
||||||
|
status: "rejected",
|
||||||
|
reason: t,
|
||||||
|
});
|
||||||
|
hN.success("已驳回实名认证");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "驳回",
|
||||||
|
}),
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
size: "sm",
|
||||||
|
variant: "ghost",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/realname/reset/${e.original.id}`, {});
|
||||||
|
hN.success("已重置实名记录");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "重置",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[d],
|
||||||
|
),
|
||||||
|
p = PKt({
|
||||||
|
data: u?.data?.data ?? [],
|
||||||
|
columns: g,
|
||||||
|
state: { columnVisibility: o, rowSelection: s, pagination: l },
|
||||||
|
getRowId: (e) => String(e.id),
|
||||||
|
rowCount: u?.data?.pagination?.total ?? 0,
|
||||||
|
manualPagination: !0,
|
||||||
|
enableRowSelection: !0,
|
||||||
|
onRowSelectionChange: a,
|
||||||
|
onColumnVisibilityChange: o,
|
||||||
|
onPaginationChange: c,
|
||||||
|
getCoreRowModel: LKt(),
|
||||||
|
getPaginationRowModel: OKt(),
|
||||||
|
});
|
||||||
|
return Q.jsxs("div", {
|
||||||
|
className: "space-y-4",
|
||||||
|
children: [
|
||||||
|
Q.jsx(codexNativeSearchToolbar, {
|
||||||
|
keyword: e,
|
||||||
|
setKeyword: t,
|
||||||
|
onSearch: () => {
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
i(e.trim());
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
t("");
|
||||||
|
i("");
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
},
|
||||||
|
refetch: d,
|
||||||
|
actions: Q.jsxs(Q.Fragment, {
|
||||||
|
children: [
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
variant: "outline",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/realname/sync-all`, {});
|
||||||
|
hN.success("已同步全部实名状态");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "同步全部",
|
||||||
|
}),
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/realname/approve-all`, {});
|
||||||
|
hN.success("已全部通过");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "全部通过",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Q.jsx(QKt, {
|
||||||
|
table: p,
|
||||||
|
isLoading: h,
|
||||||
|
showPagination: !0,
|
||||||
|
mobilePrimaryField: "email",
|
||||||
|
mobileGridFields: ["real_name", "status", "identity_no_masked"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeRealnamePage() {
|
||||||
|
return codexNativePageLayout("实名认证", "审核实名记录并同步实名状态。", Q.jsx(codexNativeRealnameTable, {}));
|
||||||
|
}
|
||||||
|
function codexNativeOnlineDevicesTable() {
|
||||||
|
const [e, t] = H.useState(""),
|
||||||
|
[n, i] = H.useState(""),
|
||||||
|
[r, o] = H.useState({}),
|
||||||
|
[s, a] = H.useState({}),
|
||||||
|
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
|
||||||
|
{
|
||||||
|
refetch: d,
|
||||||
|
data: u,
|
||||||
|
isLoading: h,
|
||||||
|
} = gC({
|
||||||
|
queryKey: ["codexNativeOnlineDevices", l, n],
|
||||||
|
queryFn: () =>
|
||||||
|
TL(`${RL()}/user-online-devices/users`, {
|
||||||
|
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
g = H.useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: () => "用户 ID",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "font-mono text-sm font-medium", children: e.getValue("id") }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: () => "邮箱",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subscription_name",
|
||||||
|
header: () => "套餐",
|
||||||
|
cell: ({ row: e }) => e.getValue("subscription_name") || "-",
|
||||||
|
},
|
||||||
|
JKt.display({
|
||||||
|
id: "online_devices",
|
||||||
|
header: () => "在线 IP",
|
||||||
|
cell: ({ row: e }) => {
|
||||||
|
const t = e.original.online_devices || [];
|
||||||
|
return t.length
|
||||||
|
? Q.jsx("div", {
|
||||||
|
className: "flex flex-col gap-1 font-mono text-xs",
|
||||||
|
children: t.map((e, t) => Q.jsx("span", { children: e }, `${e}-${t}`)),
|
||||||
|
})
|
||||||
|
: "-";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessorKey: "online_count",
|
||||||
|
header: () => "数量",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "font-mono text-sm", children: e.getValue("online_count") || 0 }),
|
||||||
|
},
|
||||||
|
JKt.display({
|
||||||
|
id: "status",
|
||||||
|
header: () => "Status",
|
||||||
|
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessorKey: "last_online_text",
|
||||||
|
header: () => "最后在线",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "text-nowrap text-sm text-muted-foreground", children: e.getValue("last_online_text") || "-" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
p = PKt({
|
||||||
|
data: u?.data?.list ?? [],
|
||||||
|
columns: g,
|
||||||
|
state: { columnVisibility: r, rowSelection: s, pagination: l },
|
||||||
|
getRowId: (e) => String(e.id),
|
||||||
|
rowCount: u?.data?.pagination?.total ?? 0,
|
||||||
|
manualPagination: !0,
|
||||||
|
enableRowSelection: !0,
|
||||||
|
onRowSelectionChange: a,
|
||||||
|
onColumnVisibilityChange: o,
|
||||||
|
onPaginationChange: c,
|
||||||
|
getCoreRowModel: LKt(),
|
||||||
|
getPaginationRowModel: OKt(),
|
||||||
|
});
|
||||||
|
return Q.jsxs("div", {
|
||||||
|
className: "space-y-4",
|
||||||
|
children: [
|
||||||
|
Q.jsx(codexNativeSearchToolbar, {
|
||||||
|
keyword: e,
|
||||||
|
setKeyword: t,
|
||||||
|
onSearch: () => {
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
i(e.trim());
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
t("");
|
||||||
|
i("");
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
},
|
||||||
|
refetch: d,
|
||||||
|
}),
|
||||||
|
Q.jsx(QKt, {
|
||||||
|
table: p,
|
||||||
|
isLoading: h,
|
||||||
|
showPagination: !0,
|
||||||
|
mobilePrimaryField: "email",
|
||||||
|
mobileGridFields: ["subscription_name", "online_count", "status", "last_online_text"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeOnlineDevicesPage() {
|
||||||
|
return codexNativePageLayout("在线设备", "查看用户在线 IP、数量和最后在线时间。", Q.jsx(codexNativeOnlineDevicesTable, {}));
|
||||||
|
}
|
||||||
|
function codexNativeIPv6Table() {
|
||||||
|
const [e, t] = H.useState(""),
|
||||||
|
[n, i] = H.useState(""),
|
||||||
|
[r, o] = H.useState({}),
|
||||||
|
[s, a] = H.useState({}),
|
||||||
|
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
|
||||||
|
{
|
||||||
|
refetch: d,
|
||||||
|
data: u,
|
||||||
|
isLoading: h,
|
||||||
|
} = gC({
|
||||||
|
queryKey: ["codexNativeIPv6", l, n],
|
||||||
|
queryFn: () =>
|
||||||
|
TL(`${RL()}/user-add-ipv6-subscription/users`, {
|
||||||
|
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
g = H.useMemo(
|
||||||
|
() => [
|
||||||
|
JKt.display({
|
||||||
|
id: "relation",
|
||||||
|
header: () => "主从关系",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
codexNativeRelationChip(e.original.id, e.original.shadow_user_id || "-"),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: () => "主账号",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ipv6_email",
|
||||||
|
header: () => "IPv6 账号",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsx("div", { className: "font-mono text-xs", children: e.getValue("ipv6_email") || "-" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "plan_name",
|
||||||
|
header: () => "套餐",
|
||||||
|
cell: ({ row: e }) => e.getValue("plan_name") || "-",
|
||||||
|
},
|
||||||
|
JKt.display({
|
||||||
|
id: "status",
|
||||||
|
header: () => "状态",
|
||||||
|
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
|
||||||
|
}),
|
||||||
|
JKt.display({
|
||||||
|
id: "actions",
|
||||||
|
header: () => "操作",
|
||||||
|
cell: ({ row: e }) =>
|
||||||
|
Q.jsxs("div", {
|
||||||
|
className: "flex flex-wrap items-center gap-2",
|
||||||
|
children: [
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
size: "sm",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/user-add-ipv6-subscription/enable/${e.original.id}`, {});
|
||||||
|
hN.success("已开通并同步 IPv6 子账号");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "开通并同步",
|
||||||
|
}),
|
||||||
|
Q.jsx(Nm, {
|
||||||
|
size: "sm",
|
||||||
|
variant: "outline",
|
||||||
|
className: "h-8",
|
||||||
|
onClick: async () => {
|
||||||
|
await IL(`${RL()}/user-add-ipv6-subscription/sync-password/${e.original.id}`, {});
|
||||||
|
hN.success("已同步密码");
|
||||||
|
d();
|
||||||
|
},
|
||||||
|
children: "同步密码",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[d],
|
||||||
|
),
|
||||||
|
p = PKt({
|
||||||
|
data: u?.data?.list ?? [],
|
||||||
|
columns: g,
|
||||||
|
state: { columnVisibility: r, rowSelection: s, pagination: l },
|
||||||
|
getRowId: (e) => String(e.id),
|
||||||
|
rowCount: u?.data?.pagination?.total ?? 0,
|
||||||
|
manualPagination: !0,
|
||||||
|
enableRowSelection: !0,
|
||||||
|
onRowSelectionChange: a,
|
||||||
|
onColumnVisibilityChange: o,
|
||||||
|
onPaginationChange: c,
|
||||||
|
getCoreRowModel: LKt(),
|
||||||
|
getPaginationRowModel: OKt(),
|
||||||
|
});
|
||||||
|
return Q.jsxs("div", {
|
||||||
|
className: "space-y-4",
|
||||||
|
children: [
|
||||||
|
Q.jsx(codexNativeSearchToolbar, {
|
||||||
|
keyword: e,
|
||||||
|
setKeyword: t,
|
||||||
|
onSearch: () => {
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
i(e.trim());
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
t("");
|
||||||
|
i("");
|
||||||
|
c((e) => ({ ...e, pageIndex: 0 }));
|
||||||
|
},
|
||||||
|
refetch: d,
|
||||||
|
}),
|
||||||
|
Q.jsx(QKt, {
|
||||||
|
table: p,
|
||||||
|
isLoading: h,
|
||||||
|
showPagination: !0,
|
||||||
|
mobilePrimaryField: "email",
|
||||||
|
mobileGridFields: ["ipv6_email", "plan_name", "status"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function codexNativeIPv6Page() {
|
||||||
|
return codexNativePageLayout("IPv6 子账号", "管理 IPv6 子账号开通、主从关系和密码同步。", Q.jsx(codexNativeIPv6Table, {}));
|
||||||
|
}
|
||||||
function c8t() {
|
function c8t() {
|
||||||
const [e] = Hg(),
|
const [e] = Hg(),
|
||||||
[t, n] = H.useState({}),
|
[t, n] = H.useState({}),
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ export const navigationGroups = [
|
|||||||
key: "config",
|
key: "config",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/config/system", titleKey: "nav:systemConfig" },
|
{ href: "/config/system", titleKey: "nav:systemConfig" },
|
||||||
{ href: "/config/plugin", titleKey: "nav:pluginManagement" },
|
|
||||||
{ href: "/config/theme", titleKey: "nav:themeConfig" },
|
|
||||||
{ href: "/config/notice", titleKey: "nav:noticeManagement" },
|
{ href: "/config/notice", titleKey: "nav:noticeManagement" },
|
||||||
{ href: "/config/payment", titleKey: "nav:paymentConfig" },
|
|
||||||
{ href: "/config/knowledge", titleKey: "nav:knowledgeManagement" },
|
{ href: "/config/knowledge", titleKey: "nav:knowledgeManagement" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -16,10 +13,7 @@ export const navigationGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "finance",
|
key: "finance",
|
||||||
items: [
|
items: [{ href: "/finance/plan", titleKey: "nav:planManagement" }],
|
||||||
{ href: "/finance/plan", titleKey: "nav:planManagement" },
|
|
||||||
{ href: "/finance/gift-card", titleKey: "nav:giftCardManagement" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "user",
|
key: "user",
|
||||||
@@ -37,6 +31,7 @@ export const navigationGroups = [
|
|||||||
{ href: "/config/system/invite", titleKey: "nav:inviteConfig" },
|
{ href: "/config/system/invite", titleKey: "nav:inviteConfig" },
|
||||||
{ href: "/config/system/server", titleKey: "nav:serverConfig" },
|
{ href: "/config/system/server", titleKey: "nav:serverConfig" },
|
||||||
{ href: "/config/system/email", titleKey: "nav:emailConfig" },
|
{ href: "/config/system/email", titleKey: "nav:emailConfig" },
|
||||||
|
{ href: "/config/theme", titleKey: "nav:themeConfig" },
|
||||||
{ href: "/config/system/telegram", titleKey: "nav:telegramConfig" },
|
{ href: "/config/system/telegram", titleKey: "nav:telegramConfig" },
|
||||||
{ href: "/config/system/app", titleKey: "nav:appConfig" },
|
{ href: "/config/system/app", titleKey: "nav:appConfig" },
|
||||||
{ href: "/config/system/subscribe-template", titleKey: "nav:subscribeTemplateConfig" },
|
{ href: "/config/system/subscribe-template", titleKey: "nav:subscribeTemplateConfig" },
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import SystemEmailPage from "../pages/config/system/SystemEmailPage.js";
|
|||||||
import SystemTelegramPage from "../pages/config/system/SystemTelegramPage.js";
|
import SystemTelegramPage from "../pages/config/system/SystemTelegramPage.js";
|
||||||
import SystemAppPage from "../pages/config/system/SystemAppPage.js";
|
import SystemAppPage from "../pages/config/system/SystemAppPage.js";
|
||||||
import SubscribeTemplatePage from "../pages/config/system/SubscribeTemplatePage.js";
|
import SubscribeTemplatePage from "../pages/config/system/SubscribeTemplatePage.js";
|
||||||
import PaymentConfigPage from "../pages/config/PaymentConfigPage.js";
|
|
||||||
import PluginManagementPage from "../pages/config/PluginManagementPage.js";
|
import PluginManagementPage from "../pages/config/PluginManagementPage.js";
|
||||||
import ThemeConfigPage from "../pages/config/ThemeConfigPage.js";
|
import ThemeConfigPage from "../pages/config/ThemeConfigPage.js";
|
||||||
import NoticeManagementPage from "../pages/config/NoticeManagementPage.js";
|
import NoticeManagementPage from "../pages/config/NoticeManagementPage.js";
|
||||||
@@ -20,8 +19,6 @@ import ServerGroupPage from "../pages/server/ServerGroupPage.js";
|
|||||||
import ServerRoutePage from "../pages/server/ServerRoutePage.js";
|
import ServerRoutePage from "../pages/server/ServerRoutePage.js";
|
||||||
import FinancePlanPage from "../pages/finance/FinancePlanPage.js";
|
import FinancePlanPage from "../pages/finance/FinancePlanPage.js";
|
||||||
import FinanceOrderPage from "../pages/finance/FinanceOrderPage.js";
|
import FinanceOrderPage from "../pages/finance/FinanceOrderPage.js";
|
||||||
import FinanceCouponPage from "../pages/finance/FinanceCouponPage.js";
|
|
||||||
import FinanceGiftCardPage from "../pages/finance/FinanceGiftCardPage.js";
|
|
||||||
import UserManagePage from "../pages/user/UserManagePage.js";
|
import UserManagePage from "../pages/user/UserManagePage.js";
|
||||||
import UserTicketPage from "../pages/user/UserTicketPage.js";
|
import UserTicketPage from "../pages/user/UserTicketPage.js";
|
||||||
import TrafficResetLogsPage from "../pages/user/TrafficResetLogsPage.js";
|
import TrafficResetLogsPage from "../pages/user/TrafficResetLogsPage.js";
|
||||||
@@ -42,7 +39,6 @@ export const reverseRoutes = [
|
|||||||
{ path: "/config/system/telegram", page: SystemTelegramPage },
|
{ path: "/config/system/telegram", page: SystemTelegramPage },
|
||||||
{ path: "/config/system/app", page: SystemAppPage },
|
{ path: "/config/system/app", page: SystemAppPage },
|
||||||
{ path: "/config/system/subscribe-template", page: SubscribeTemplatePage },
|
{ path: "/config/system/subscribe-template", page: SubscribeTemplatePage },
|
||||||
{ path: "/config/payment", page: PaymentConfigPage },
|
|
||||||
{ path: "/config/plugin", page: PluginManagementPage },
|
{ path: "/config/plugin", page: PluginManagementPage },
|
||||||
{ path: "/config/theme", page: ThemeConfigPage },
|
{ path: "/config/theme", page: ThemeConfigPage },
|
||||||
{ path: "/config/notice", page: NoticeManagementPage },
|
{ path: "/config/notice", page: NoticeManagementPage },
|
||||||
@@ -52,8 +48,6 @@ export const reverseRoutes = [
|
|||||||
{ path: "/server/route", page: ServerRoutePage },
|
{ path: "/server/route", page: ServerRoutePage },
|
||||||
{ path: "/finance/plan", page: FinancePlanPage },
|
{ path: "/finance/plan", page: FinancePlanPage },
|
||||||
{ path: "/finance/order", page: FinanceOrderPage },
|
{ path: "/finance/order", page: FinanceOrderPage },
|
||||||
{ path: "/finance/coupon", page: FinanceCouponPage },
|
|
||||||
{ path: "/finance/gift-card", page: FinanceGiftCardPage },
|
|
||||||
{ path: "/user/manage", page: UserManagePage },
|
{ path: "/user/manage", page: UserManagePage },
|
||||||
{ path: "/user/ticket", page: UserTicketPage },
|
{ path: "/user/ticket", page: UserTicketPage },
|
||||||
{ path: "/user/traffic-reset-logs", page: TrafficResetLogsPage },
|
{ path: "/user/traffic-reset-logs", page: TrafficResetLogsPage },
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
|
||||||
|
import ThemeConfigPageView from "./ThemeConfigPage.jsx";
|
||||||
|
|
||||||
export default makeSkeletonPage({
|
export default makeRecoveredPage({
|
||||||
title: "Theme Config",
|
title: "Theme Config",
|
||||||
routePath: "/config/theme",
|
routePath: "/config/theme",
|
||||||
moduleId: "WQt",
|
moduleId: "WQt",
|
||||||
featureKey: "config.theme",
|
featureKey: "config.theme",
|
||||||
notes: ["Bundle contains theme activation and theme config editing flows."],
|
component: ThemeConfigPageView,
|
||||||
});
|
});
|
||||||
|
|||||||
226
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal file
226
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
|
||||||
|
import {
|
||||||
|
compactText,
|
||||||
|
requestJson,
|
||||||
|
} from "../../runtime/client.js";
|
||||||
|
|
||||||
|
// Wot: Wrapper structure for the config page
|
||||||
|
function Wot({ children }) {
|
||||||
|
return <div className="recovery-live-page" style={{ padding: '2rem' }}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot: Header component for sections or the page
|
||||||
|
function Hot({ title, description }) {
|
||||||
|
return (
|
||||||
|
<header className="recovery-live-hero" style={{ marginBottom: '2.5rem', borderBottom: '1px solid var(--border-soft)', pb: '1.5rem' }}>
|
||||||
|
<div style={{ maxWidth: '800px' }}>
|
||||||
|
<p className="eyebrow" style={{ color: 'var(--brand-color)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Theme Customization
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: '2.25rem', marginBottom: '0.75rem' }}>{title}</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: '1.6' }}>{description}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zot: Setting Item (Zone) component for form fields
|
||||||
|
function zot({ label, children, span = 1 }) {
|
||||||
|
return (
|
||||||
|
<div className={`recovery-table-card ${span === 2 ? 'span-2' : ''}`} style={{ padding: '1.25rem', background: 'var(--card-bg)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem', fontWeight: '500', color: 'var(--text-main)' }}>{label}</label>
|
||||||
|
<div className="form-control-wrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// QKt: The main Nebula configuration component
|
||||||
|
function QKt() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nebula_theme_color: 'aurora',
|
||||||
|
nebula_hero_slogan: '',
|
||||||
|
nebula_welcome_target: '',
|
||||||
|
nebula_register_title: '',
|
||||||
|
nebula_background_url: '',
|
||||||
|
nebula_metrics_base_url: '',
|
||||||
|
nebula_default_theme_mode: 'system',
|
||||||
|
nebula_light_logo_url: '',
|
||||||
|
nebula_dark_logo_url: '',
|
||||||
|
nebula_custom_html: '',
|
||||||
|
nebula_static_cdn_url: '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const payload = await requestJson("/config/fetch?key=nebula");
|
||||||
|
if (payload?.nebula) {
|
||||||
|
setForm(prev => ({ ...prev, ...payload.nebula }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to load configuration: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await requestJson("/config/save", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
setSuccess("Nebula configuration updated successfully.");
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to save configuration: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Wot><div className="empty-cell">Initializing Nebula settings...</div></Wot>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wot>
|
||||||
|
<Hot
|
||||||
|
title="Nebula Theme settings"
|
||||||
|
description="Optimize your user dashboard with specific Nebula theme settings and branding options."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <div className="toast toast-error" style={{ marginBottom: '1.5rem' }}>{error}</div>}
|
||||||
|
{success && <div className="toast toast-success" style={{ marginBottom: '1.5rem' }}>{success}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="modal-grid" style={{ maxWidth: '1200px' }}>
|
||||||
|
<zot label="Primary Accent Color" span={2}>
|
||||||
|
<select
|
||||||
|
value={form.nebula_theme_color}
|
||||||
|
onChange={e => setForm({...form, nebula_theme_color: e.target.value})}
|
||||||
|
className="recovery-input"
|
||||||
|
style={{ width: '100%', height: '42px' }}
|
||||||
|
>
|
||||||
|
<option value="aurora">极光蓝 (Aurora Blue)</option>
|
||||||
|
<option value="sunset">日落橙 (Sunset Orange)</option>
|
||||||
|
<option value="ember">余烬红 (Ember Red)</option>
|
||||||
|
<option value="violet">星云紫 (Violet Purple)</option>
|
||||||
|
</select>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Hero Slogan">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_hero_slogan}
|
||||||
|
onChange={e => setForm({...form, nebula_hero_slogan: e.target.value})}
|
||||||
|
placeholder="Main visual headline"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Welcome Target">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_welcome_target}
|
||||||
|
onChange={e => setForm({...form, nebula_welcome_target: e.target.value})}
|
||||||
|
placeholder="Name displayed after WELCOME TO"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Register Title">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_register_title}
|
||||||
|
onChange={e => setForm({...form, nebula_register_title: e.target.value})}
|
||||||
|
placeholder="Title on registration panel"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Default Appearance">
|
||||||
|
<select
|
||||||
|
value={form.nebula_default_theme_mode}
|
||||||
|
onChange={e => setForm({...form, nebula_default_theme_mode: e.target.value})}
|
||||||
|
className="recovery-input"
|
||||||
|
style={{ width: '100%', height: '42px' }}
|
||||||
|
>
|
||||||
|
<option value="system">Adaptive (System)</option>
|
||||||
|
<option value="dark">Dark Theme</option>
|
||||||
|
<option value="light">Light Theme</option>
|
||||||
|
</select>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Background Image URL">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_background_url}
|
||||||
|
onChange={e => setForm({...form, nebula_background_url: e.target.value})}
|
||||||
|
placeholder="Direct link to background image"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Metrics API Domain">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_metrics_base_url}
|
||||||
|
onChange={e => setForm({...form, nebula_metrics_base_url: e.target.value})}
|
||||||
|
placeholder="https://stats.example.com"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Light Mode Logo">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_light_logo_url}
|
||||||
|
onChange={e => setForm({...form, nebula_light_logo_url: e.target.value})}
|
||||||
|
placeholder="Logo for light mode"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Dark Mode Logo">
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_dark_logo_url}
|
||||||
|
onChange={e => setForm({...form, nebula_dark_logo_url: e.target.value})}
|
||||||
|
placeholder="Logo for dark mode"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Static CDN Assets" span={2}>
|
||||||
|
<input
|
||||||
|
className="recovery-input"
|
||||||
|
value={form.nebula_static_cdn_url}
|
||||||
|
onChange={e => setForm({...form, nebula_static_cdn_url: e.target.value})}
|
||||||
|
placeholder="e.g. https://cdn.example.com/nebula (no trailing slash)"
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<zot label="Custom Scripts / CSS" span={2}>
|
||||||
|
<textarea
|
||||||
|
className="recovery-input"
|
||||||
|
rows={6}
|
||||||
|
value={form.nebula_custom_html}
|
||||||
|
onChange={e => setForm({...form, nebula_custom_html: e.target.value})}
|
||||||
|
placeholder="Add analytics codes or custom styles here"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</zot>
|
||||||
|
|
||||||
|
<div className="span-2" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'flex-end', borderTop: '1px solid var(--border-soft)', paddingTop: '1.5rem' }}>
|
||||||
|
<button type="submit" className="primary-btn" disabled={saving}>
|
||||||
|
{saving ? "Persisting..." : "Save Configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Wot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QKt;
|
||||||
@@ -7,14 +7,14 @@
|
|||||||
<script>
|
<script>
|
||||||
window.settings = {{.SettingsJS}};
|
window.settings = {{.SettingsJS}};
|
||||||
</script>
|
</script>
|
||||||
<script src="/admin-assets/locales/zh-CN.js"></script>
|
<script src="/admin-assets/locales/zh-CN.js?v={{.AssetNonce}}"></script>
|
||||||
<script src="/admin-assets/locales/en-US.js"></script>
|
<script src="/admin-assets/locales/en-US.js?v={{.AssetNonce}}"></script>
|
||||||
<script src="/admin-assets/locales/ru-RU.js"></script>
|
<script src="/admin-assets/locales/ru-RU.js?v={{.AssetNonce}}"></script>
|
||||||
<script src="/admin-assets/locales/ko-KR.js"></script>
|
<script src="/admin-assets/locales/ko-KR.js?v={{.AssetNonce}}"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin-assets/assets/index-DTPKq_WI.css">
|
<link rel="stylesheet" crossorigin href="/admin-assets/assets/index-DTPKq_WI.css?v={{.AssetNonce}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" crossorigin src="/admin-assets/reverse/output/index-CO3BwsT2.pretty.js"></script>
|
<script type="module" crossorigin src="/admin-assets/reverse/output/index-CO3BwsT2.pretty.js?v={{.AssetNonce}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
926
frontend/templates/admin_plugin_panel.html
Normal file
926
frontend/templates/admin_plugin_panel.html
Normal file
@@ -0,0 +1,926 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--muted-soft: #f8fafc;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-dark: #1d4ed8;
|
||||||
|
--shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
--success-bg: #dcfce7;
|
||||||
|
--success-text: #15803d;
|
||||||
|
--warn-bg: #fef3c7;
|
||||||
|
--warn-text: #b45309;
|
||||||
|
--danger-bg: #fee2e2;
|
||||||
|
--danger-text: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 360px);
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--muted-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-fallback {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--muted-soft);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-name,
|
||||||
|
.avatar-email {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-email {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field,
|
||||||
|
.btn {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
min-width: 260px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark), var(--primary-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warn {
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: var(--warn-bg);
|
||||||
|
color: var(--warn-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.danger {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8fafc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-summary {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-input {
|
||||||
|
width: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-mask.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
max-height: min(78vh, 720px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-head {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-search {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-list {
|
||||||
|
max-height: 56vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-empty {
|
||||||
|
padding: 28px 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-path {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.pager {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-controls {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<button id="open-menu-search" class="search-trigger" type="button">
|
||||||
|
<span>搜索菜单和功能</span>
|
||||||
|
<kbd>Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="avatar-card">
|
||||||
|
<img id="user-avatar" class="avatar" alt="avatar" />
|
||||||
|
<div id="avatar-fallback" class="avatar-fallback">AD</div>
|
||||||
|
<div class="avatar-meta">
|
||||||
|
<div id="user-name" class="avatar-name">Admin</div>
|
||||||
|
<div id="user-email" class="avatar-email">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<input id="keyword" class="field" placeholder="搜索用户 ID / 邮箱" />
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right" id="toolbar-actions"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="thead"></thead>
|
||||||
|
<tbody id="tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<div class="pager-summary" id="summary">加载中...</div>
|
||||||
|
<div class="pager-controls">
|
||||||
|
<div class="pager-page">
|
||||||
|
<span>第</span>
|
||||||
|
<input id="page-input" class="field page-input" inputmode="numeric" />
|
||||||
|
<span id="page-total">/ 1 页</span>
|
||||||
|
</div>
|
||||||
|
<div class="pager-buttons">
|
||||||
|
<button id="first-btn" class="btn icon-btn" aria-label="首页">«</button>
|
||||||
|
<button id="prev-btn" class="btn icon-btn" aria-label="上一页">‹</button>
|
||||||
|
<button id="next-btn" class="btn icon-btn" aria-label="下一页">›</button>
|
||||||
|
<button id="last-btn" class="btn icon-btn" aria-label="末页">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="menu-dialog" class="dialog-mask" aria-hidden="true">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<input id="menu-search-input" class="field dialog-search" placeholder="搜索菜单和功能" />
|
||||||
|
</div>
|
||||||
|
<div id="menu-list" class="dialog-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const kind = "{{.Kind}}";
|
||||||
|
const securePath = "{{.SecurePath}}";
|
||||||
|
const apiV1Base = "/api/v1";
|
||||||
|
const apiV2Base = "/api/v2";
|
||||||
|
const apiBase = `${apiV2Base}/${securePath}`;
|
||||||
|
const state = { page: 1, lastPage: 1, total: 0, user: null };
|
||||||
|
const menuItems = [
|
||||||
|
{ title: "系统配置", path: "/config/system" },
|
||||||
|
{ title: "公告管理", path: "/config/notice" },
|
||||||
|
{ title: "知识库管理", path: "/config/knowledge" },
|
||||||
|
{ title: "节点管理", path: "/server/manage" },
|
||||||
|
{ title: "权限组管理", path: "/server/group" },
|
||||||
|
{ title: "路由管理", path: "/server/route" },
|
||||||
|
{ title: "套餐管理", path: "/finance/plan" },
|
||||||
|
{ title: "订单管理", path: "/finance/order" },
|
||||||
|
{ title: "用户管理", path: "/user/manage" },
|
||||||
|
{ title: "工单管理", path: "/user/ticket" },
|
||||||
|
{ title: "实名认证", path: "/user/realname" },
|
||||||
|
{ title: "在线设备", path: "/user/online-devices" },
|
||||||
|
{ title: "IPv6 子账号", path: "/user/ipv6-subscription" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseToken(rawValue) {
|
||||||
|
if (!rawValue || typeof rawValue !== "string") return "";
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
const nested = parsed && (parsed.value || parsed.token || parsed.access_token || parsed.accessToken);
|
||||||
|
if (nested) return String(nested).startsWith("Bearer ") ? nested : `Bearer ${nested}`;
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
return trimmed.startsWith("Bearer ") ? trimmed : `Bearer ${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminToken() {
|
||||||
|
const keys = [
|
||||||
|
"XBOARD_ACCESS_TOKEN",
|
||||||
|
"Xboard_access_token",
|
||||||
|
"XBOARD_ADMIN_ACCESS_TOKEN",
|
||||||
|
"xboard_access_token",
|
||||||
|
"__gopanel_admin_auth__",
|
||||||
|
];
|
||||||
|
for (const storage of [window.localStorage, window.sessionStorage]) {
|
||||||
|
if (!storage) continue;
|
||||||
|
for (const key of keys) {
|
||||||
|
const token = parseToken(storage.getItem(key));
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(url, method = "GET", body) {
|
||||||
|
const token = getAdminToken();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: token,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((payload && (payload.message || payload.msg)) || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return payload && typeof payload.data !== "undefined" ? payload.data : payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
try {
|
||||||
|
const user = await request(`${apiV2Base}/user/info`);
|
||||||
|
state.user = user || {};
|
||||||
|
const email = state.user.email || "admin@example.com";
|
||||||
|
const name = email.includes("@") ? email.split("@")[0] : email;
|
||||||
|
const initials = (name || "AD").slice(0, 2).toUpperCase();
|
||||||
|
document.getElementById("user-name").textContent = name || "Admin";
|
||||||
|
document.getElementById("user-email").textContent = email;
|
||||||
|
document.getElementById("avatar-fallback").textContent = initials;
|
||||||
|
|
||||||
|
const avatar = document.getElementById("user-avatar");
|
||||||
|
if (state.user.avatar_url) {
|
||||||
|
avatar.src = state.user.avatar_url;
|
||||||
|
avatar.style.display = "block";
|
||||||
|
} else {
|
||||||
|
avatar.style.display = "none";
|
||||||
|
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
avatar.onerror = () => {
|
||||||
|
avatar.style.display = "none";
|
||||||
|
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||||
|
};
|
||||||
|
avatar.onload = () => {
|
||||||
|
avatar.style.display = "block";
|
||||||
|
document.getElementById("avatar-fallback").style.display = "none";
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById("user-email").textContent = "管理员";
|
||||||
|
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(text) {
|
||||||
|
const normalized = String(text || "-").toLowerCase();
|
||||||
|
let cls = "danger";
|
||||||
|
if (/(approved|active|enabled|ready)/.test(normalized)) cls = "ok";
|
||||||
|
else if (/(pending|eligible|unverified)/.test(normalized)) cls = "warn";
|
||||||
|
return `<span class="badge ${cls}">${text || "-"}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationChip(left, right) {
|
||||||
|
return `<span class="relation mono">${left}<span>→</span>${right}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPagination(pagination) {
|
||||||
|
state.page = pagination.current || 1;
|
||||||
|
state.lastPage = pagination.last_page || 1;
|
||||||
|
state.total = pagination.total || 0;
|
||||||
|
document.getElementById("summary").textContent = `共 ${state.total} 条数据`;
|
||||||
|
document.getElementById("page-input").value = String(state.page);
|
||||||
|
document.getElementById("page-total").textContent = `/ ${state.lastPage} 页`;
|
||||||
|
document.getElementById("first-btn").disabled = state.page <= 1;
|
||||||
|
document.getElementById("prev-btn").disabled = state.page <= 1;
|
||||||
|
document.getElementById("next-btn").disabled = state.page >= state.lastPage;
|
||||||
|
document.getElementById("last-btn").disabled = state.page >= state.lastPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToPage(value) {
|
||||||
|
const nextPage = Number.parseInt(String(value || "").trim(), 10);
|
||||||
|
if (Number.isNaN(nextPage)) {
|
||||||
|
document.getElementById("page-input").value = String(state.page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetPage = Math.min(Math.max(nextPage, 1), Math.max(state.lastPage, 1));
|
||||||
|
loadData(targetPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupToolbar() {
|
||||||
|
const toolbar = document.getElementById("toolbar-actions");
|
||||||
|
if (kind === "realname") {
|
||||||
|
toolbar.innerHTML = `
|
||||||
|
<button class="btn btn-warn" id="sync-all-btn">同步全部</button>
|
||||||
|
<button class="btn btn-primary" id="approve-all-btn">全部通过</button>
|
||||||
|
`;
|
||||||
|
document.getElementById("sync-all-btn").onclick = async () => {
|
||||||
|
await request(`${apiBase}/realname/sync-all`, "POST", {});
|
||||||
|
await loadData(state.page);
|
||||||
|
};
|
||||||
|
document.getElementById("approve-all-btn").onclick = async () => {
|
||||||
|
await request(`${apiBase}/realname/approve-all`, "POST", {});
|
||||||
|
await loadData(state.page);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
toolbar.innerHTML = `<button class="btn" id="refresh-btn">刷新</button>`;
|
||||||
|
document.getElementById("refresh-btn").onclick = () => loadData(state.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(page) {
|
||||||
|
const keyword = encodeURIComponent(document.getElementById("keyword").value.trim());
|
||||||
|
if (kind === "realname") return `${apiBase}/realname/records?page=${page}&per_page=20&keyword=${keyword}`;
|
||||||
|
if (kind === "online-devices") return `${apiBase}/user-online-devices/users?page=${page}&per_page=20&keyword=${keyword}`;
|
||||||
|
return `${apiBase}/user-add-ipv6-subscription/users?page=${page}&per_page=20&keyword=${keyword}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRealname(payload) {
|
||||||
|
const list = payload.data || [];
|
||||||
|
document.getElementById("thead").innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th>用户 ID</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>姓名</th>
|
||||||
|
<th>证件号</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
document.getElementById("tbody").innerHTML = list.length
|
||||||
|
? list
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td class="mono">${item.id}</td>
|
||||||
|
<td>${item.email || "-"}</td>
|
||||||
|
<td>${item.real_name || "-"}</td>
|
||||||
|
<td class="mono">${item.identity_no_masked || "-"}</td>
|
||||||
|
<td>${statusBadge(item.status_label || item.status)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" onclick="realnameReview(${item.id}, 'approved')">通过</button>
|
||||||
|
<button class="btn" onclick="realnameReview(${item.id}, 'rejected')">驳回</button>
|
||||||
|
<button class="btn" onclick="realnameReset(${item.id})">重置</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||||
|
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOnlineDevices(payload) {
|
||||||
|
const list = payload.list || [];
|
||||||
|
document.getElementById("thead").innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th>用户 ID</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>套餐</th>
|
||||||
|
<th>在线 IP</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>最后在线</th>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
document.getElementById("tbody").innerHTML = list.length
|
||||||
|
? list
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td class="mono">${item.id}</td>
|
||||||
|
<td>${item.email || "-"}</td>
|
||||||
|
<td>${item.subscription_name || "-"}</td>
|
||||||
|
<td>${(item.online_devices || []).join("<br>") || "-"}</td>
|
||||||
|
<td class="mono">${item.online_count || 0}</td>
|
||||||
|
<td>${item.last_online_text || "-"}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||||
|
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIPv6(payload) {
|
||||||
|
const list = payload.list || [];
|
||||||
|
document.getElementById("thead").innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th>主从关系</th>
|
||||||
|
<th>主账号</th>
|
||||||
|
<th>IPv6 账号</th>
|
||||||
|
<th>套餐</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
document.getElementById("tbody").innerHTML = list.length
|
||||||
|
? list
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td>${relationChip(item.id, item.shadow_user_id || "-")}</td>
|
||||||
|
<td>${item.email || "-"}</td>
|
||||||
|
<td class="mono">${item.ipv6_email || "-"}</td>
|
||||||
|
<td>${item.plan_name || "-"}</td>
|
||||||
|
<td>${statusBadge(item.status_label || item.status)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" onclick="ipv6Enable(${item.id})">开通并同步</button>
|
||||||
|
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||||
|
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(page = 1) {
|
||||||
|
const payload = await request(endpoint(page));
|
||||||
|
const pagination =
|
||||||
|
kind === "realname"
|
||||||
|
? renderRealname(payload)
|
||||||
|
: kind === "online-devices"
|
||||||
|
? renderOnlineDevices(payload)
|
||||||
|
: renderIPv6(payload);
|
||||||
|
setPagination(pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realnameReview(id, status) {
|
||||||
|
const reason = status === "rejected" ? window.prompt("请输入驳回原因", "") || "" : "";
|
||||||
|
await request(`${apiBase}/realname/review/${id}`, "POST", { status, reason });
|
||||||
|
await loadData(state.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realnameReset(id) {
|
||||||
|
await request(`${apiBase}/realname/reset/${id}`, "POST", {});
|
||||||
|
await loadData(state.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ipv6Enable(id) {
|
||||||
|
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
|
||||||
|
await loadData(state.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ipv6SyncPassword(id) {
|
||||||
|
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
|
||||||
|
await loadData(state.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenuDialog() {
|
||||||
|
document.getElementById("menu-dialog").classList.add("is-open");
|
||||||
|
document.getElementById("menu-dialog").setAttribute("aria-hidden", "false");
|
||||||
|
document.getElementById("menu-search-input").focus();
|
||||||
|
renderMenuList(document.getElementById("menu-search-input").value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenuDialog() {
|
||||||
|
document.getElementById("menu-dialog").classList.remove("is-open");
|
||||||
|
document.getElementById("menu-dialog").setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path) {
|
||||||
|
const target = `/${securePath}${path}`;
|
||||||
|
if (window.top && window.top !== window) {
|
||||||
|
window.top.location.assign(target);
|
||||||
|
} else {
|
||||||
|
window.location.assign(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenuList(keyword = "") {
|
||||||
|
const list = document.getElementById("menu-list");
|
||||||
|
const normalized = String(keyword || "").trim().toLowerCase();
|
||||||
|
const filtered = menuItems.filter((item) => {
|
||||||
|
const haystack = `${item.title} ${item.path}`.toLowerCase();
|
||||||
|
return !normalized || haystack.includes(normalized);
|
||||||
|
});
|
||||||
|
list.innerHTML = filtered.length
|
||||||
|
? filtered
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<button class="menu-item" type="button" data-path="${item.path}">
|
||||||
|
<div>
|
||||||
|
<div class="menu-title">${item.title}</div>
|
||||||
|
<div class="menu-path">${item.path}</div>
|
||||||
|
</div>
|
||||||
|
<span class="menu-path">进入</span>
|
||||||
|
</button>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="dialog-empty">没有匹配的菜单或功能</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("open-menu-search").addEventListener("click", openMenuDialog);
|
||||||
|
document.getElementById("menu-dialog").addEventListener("click", (event) => {
|
||||||
|
if (event.target.id === "menu-dialog") {
|
||||||
|
closeMenuDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("menu-search-input").addEventListener("input", (event) => {
|
||||||
|
renderMenuList(event.target.value);
|
||||||
|
});
|
||||||
|
document.getElementById("menu-list").addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-path]");
|
||||||
|
if (!button) return;
|
||||||
|
closeMenuDialog();
|
||||||
|
navigateTo(button.getAttribute("data-path"));
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
openMenuDialog();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeMenuDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("keyword").addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
loadData(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("page-input").addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
jumpToPage(event.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("page-input").addEventListener("blur", (event) => {
|
||||||
|
jumpToPage(event.target.value);
|
||||||
|
});
|
||||||
|
document.getElementById("first-btn").onclick = () => state.page > 1 && loadData(1);
|
||||||
|
document.getElementById("prev-btn").onclick = () => state.page > 1 && loadData(state.page - 1);
|
||||||
|
document.getElementById("next-btn").onclick = () => state.page < state.lastPage && loadData(state.page + 1);
|
||||||
|
document.getElementById("last-btn").onclick = () => state.page < state.lastPage && loadData(state.lastPage);
|
||||||
|
|
||||||
|
setupToolbar();
|
||||||
|
loadCurrentUser();
|
||||||
|
loadData().catch((error) => {
|
||||||
|
document.getElementById("summary").textContent = error.message || "加载失败";
|
||||||
|
document.getElementById("page-input").value = "1";
|
||||||
|
document.getElementById("page-total").textContent = "/ 1 页";
|
||||||
|
document.getElementById("tbody").innerHTML = `<tr><td colspan="6" class="empty">${error.message || "加载失败"}</td></tr>`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
ipv6AuthToken: getStoredIpv6Token(),
|
ipv6AuthToken: getStoredIpv6Token(),
|
||||||
ipv6User: null,
|
ipv6User: null,
|
||||||
ipv6Subscribe: null,
|
ipv6Subscribe: null,
|
||||||
|
ipv6Eligibility: null,
|
||||||
ipv6Servers: [],
|
ipv6Servers: [],
|
||||||
sessionOverview: null,
|
sessionOverview: null,
|
||||||
ipv6SessionOverview: null,
|
ipv6SessionOverview: null,
|
||||||
@@ -105,22 +106,23 @@
|
|||||||
fetchJson("/api/v1/user/getSubscribe", { method: "GET" }),
|
fetchJson("/api/v1/user/getSubscribe", { method: "GET" }),
|
||||||
fetchJson("/api/v1/user/getStat", { method: "GET" }),
|
fetchJson("/api/v1/user/getStat", { method: "GET" }),
|
||||||
fetchJson("/api/v1/user/server/fetch", { method: "GET" }),
|
fetchJson("/api/v1/user/server/fetch", { method: "GET" }),
|
||||||
fetchJson("/api/v1/user/knowledge/fetch", { method: "GET" }),
|
|
||||||
fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }),
|
fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }),
|
||||||
fetchSessionOverview(),
|
fetchSessionOverview(),
|
||||||
fetchJson("/api/v1/user/comm/config", { method: "GET" }),
|
fetchJson("/api/v1/user/comm/config", { method: "GET" }),
|
||||||
fetchRealNameVerificationStatus()
|
fetchRealNameVerificationStatus(),
|
||||||
|
fetchJson("/api/v1/user/user-add-ipv6-subscription/check", { method: "GET" })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
state.user = unwrap(results[0]);
|
state.user = unwrap(results[0]);
|
||||||
state.subscribe = unwrap(results[1]);
|
state.subscribe = unwrap(results[1]);
|
||||||
state.stats = unwrap(results[2]);
|
state.stats = unwrap(results[2]);
|
||||||
state.servers = unwrap(results[3]) || [];
|
state.servers = unwrap(results[3]) || [];
|
||||||
state.knowledge = unwrap(results[4]) || [];
|
state.knowledge = [];
|
||||||
state.tickets = unwrap(results[5]) || [];
|
state.tickets = unwrap(results[4]) || [];
|
||||||
state.sessionOverview = results[6];
|
state.sessionOverview = results[5];
|
||||||
state.appConfig = unwrap(results[7]) || {};
|
state.appConfig = unwrap(results[6]) || {};
|
||||||
state.realNameVerification = results[8] || null;
|
state.realNameVerification = results[7] || null;
|
||||||
|
state.ipv6Eligibility = unwrap(results[8]) || null;
|
||||||
|
|
||||||
if (state.ipv6AuthToken) {
|
if (state.ipv6AuthToken) {
|
||||||
try {
|
try {
|
||||||
@@ -917,7 +919,12 @@
|
|||||||
async function handleEnableIpv6(actionEl) {
|
async function handleEnableIpv6(actionEl) {
|
||||||
actionEl.disabled = true;
|
actionEl.disabled = true;
|
||||||
try {
|
try {
|
||||||
await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
|
var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
|
||||||
|
var payload = unwrap(response) || {};
|
||||||
|
if (payload.auth_data) {
|
||||||
|
saveIpv6Token(payload.auth_data);
|
||||||
|
state.ipv6AuthToken = getStoredIpv6Token();
|
||||||
|
}
|
||||||
showMessage("IPv6 订阅已开启,正在刷新...", "success");
|
showMessage("IPv6 订阅已开启,正在刷新...", "success");
|
||||||
// Try to login to IPv6 account if possible, or just refresh dashboard
|
// Try to login to IPv6 account if possible, or just refresh dashboard
|
||||||
// Since we don't have the password here (state doesn't keep it),
|
// Since we don't have the password here (state doesn't keep it),
|
||||||
@@ -1394,13 +1401,6 @@
|
|||||||
'</section>'
|
'</section>'
|
||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
if (state.currentRoute === "notices") {
|
|
||||||
return [
|
|
||||||
'<section class="dashboard-grid">',
|
|
||||||
renderKnowledgeSection(state.knowledge || []),
|
|
||||||
'</section>'
|
|
||||||
].join("");
|
|
||||||
}
|
|
||||||
if (state.currentRoute === "tickets") {
|
if (state.currentRoute === "tickets") {
|
||||||
return [
|
return [
|
||||||
'<section class="dashboard-grid">',
|
'<section class="dashboard-grid">',
|
||||||
@@ -1425,7 +1425,7 @@
|
|||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
var html = [
|
||||||
'<section class="dashboard-grid dashboard-grid--overview">',
|
'<section class="dashboard-grid dashboard-grid--overview">',
|
||||||
renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, "span-12 section-card--overview"),
|
renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, "span-12 section-card--overview"),
|
||||||
renderIpv6TrafficOverviewCard("span-12"),
|
renderIpv6TrafficOverviewCard("span-12"),
|
||||||
@@ -1433,6 +1433,7 @@
|
|||||||
renderRealNameVerificationOverviewCard("span-12 section-card--overview"),
|
renderRealNameVerificationOverviewCard("span-12 section-card--overview"),
|
||||||
'</section>'
|
'</section>'
|
||||||
].join("");
|
].join("");
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
|
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
|
||||||
@@ -1527,6 +1528,7 @@
|
|||||||
sidebarLink("security", "账号安全", "修改密码与安全设置"),
|
sidebarLink("security", "账号安全", "修改密码与安全设置"),
|
||||||
'</div>'
|
'</div>'
|
||||||
].join("");
|
].join("");
|
||||||
|
content = content.replace(/<button class="sidebar-link[^"]*" data-action="navigate" data-route="notices"[\s\S]*?<\/button>/, "");
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
'</div>',
|
'</div>',
|
||||||
sidebarLink("real-name", "\u5b9e\u540d\u8ba4\u8bc1", "\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001") + '</div>'
|
sidebarLink("real-name", "\u5b9e\u540d\u8ba4\u8bc1", "\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001") + '</div>'
|
||||||
@@ -1581,6 +1583,7 @@
|
|||||||
extraClass = extraClass || "span-7";
|
extraClass = extraClass || "span-7";
|
||||||
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
||||||
var user = isIpv6 ? state.ipv6User : state.user;
|
var user = isIpv6 ? state.ipv6User : state.user;
|
||||||
|
var ipv6Eligibility = state.ipv6Eligibility || {};
|
||||||
var prefix = isIpv6 ? "IPv6 " : "";
|
var prefix = isIpv6 ? "IPv6 " : "";
|
||||||
var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe";
|
var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe";
|
||||||
var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security";
|
var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security";
|
||||||
@@ -1592,7 +1595,7 @@
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
var html = [
|
||||||
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
||||||
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
|
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
|
||||||
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
|
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
|
||||||
@@ -1607,6 +1610,18 @@
|
|||||||
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">开启 IPv6 订阅</button></div>' : '',
|
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">开启 IPv6 订阅</button></div>' : '',
|
||||||
"</article>"
|
"</article>"
|
||||||
].join("");
|
].join("");
|
||||||
|
if (isIpv6 && !state.ipv6AuthToken) {
|
||||||
|
if (!ipv6Eligibility.allowed) {
|
||||||
|
html = html.replace(
|
||||||
|
/<div class="card-footer" style="margin-top:16px;border-top:1px solid var\(--border-color\);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">[\s\S]*?<\/button><\/div>/,
|
||||||
|
'<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><div class="empty-state" style="padding:0;text-align:left">' + escapeHtml(ipv6Eligibility.reason || ipv6Eligibility.status_label || "Current account is not eligible for IPv6 self-service") + '</div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ipv6Eligibility.is_active) {
|
||||||
|
html = html.replace(/<button class="btn btn-primary" data-action="sync-ipv6-password">[\s\S]*?<\/button>/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOpsSection(stats, overview) {
|
function renderOpsSection(stats, overview) {
|
||||||
@@ -2198,7 +2213,7 @@
|
|||||||
var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-";
|
var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-";
|
||||||
var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-";
|
var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-";
|
||||||
|
|
||||||
return [
|
var html = [
|
||||||
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-12") + '">',
|
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-12") + '">',
|
||||||
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
|
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
|
||||||
'<div class="kpi-row">',
|
'<div class="kpi-row">',
|
||||||
@@ -2387,7 +2402,6 @@
|
|||||||
"access-history",
|
"access-history",
|
||||||
"nodes",
|
"nodes",
|
||||||
"ipv6-nodes",
|
"ipv6-nodes",
|
||||||
"notices",
|
|
||||||
"tickets",
|
"tickets",
|
||||||
"real-name",
|
"real-name",
|
||||||
"security"
|
"security"
|
||||||
@@ -2479,6 +2493,7 @@
|
|||||||
clearIpv6Token();
|
clearIpv6Token();
|
||||||
state.ipv6User = null;
|
state.ipv6User = null;
|
||||||
state.ipv6Subscribe = null;
|
state.ipv6Subscribe = null;
|
||||||
|
state.ipv6Eligibility = null;
|
||||||
state.ipv6Servers = [];
|
state.ipv6Servers = [];
|
||||||
state.ipv6SessionOverview = null;
|
state.ipv6SessionOverview = null;
|
||||||
state.ipv6CachedSubNodes = null;
|
state.ipv6CachedSubNodes = null;
|
||||||
@@ -2505,6 +2520,7 @@
|
|||||||
state.ipv6AuthToken = "";
|
state.ipv6AuthToken = "";
|
||||||
state.ipv6User = null;
|
state.ipv6User = null;
|
||||||
state.ipv6Subscribe = null;
|
state.ipv6Subscribe = null;
|
||||||
|
state.ipv6Eligibility = null;
|
||||||
state.ipv6Servers = [];
|
state.ipv6Servers = [];
|
||||||
state.sessionOverview = null;
|
state.sessionOverview = null;
|
||||||
state.ipv6SessionOverview = null;
|
state.ipv6SessionOverview = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"xboard-go/internal/config"
|
"xboard-go/internal/config"
|
||||||
|
"xboard-go/internal/model"
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -23,12 +24,21 @@ func InitDB() {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := DB.AutoMigrate(
|
||||||
|
&model.RealNameAuth{},
|
||||||
|
&model.UserOnlineDevice{},
|
||||||
|
&model.UserIPv6Subscription{},
|
||||||
|
); err != nil {
|
||||||
|
log.Fatalf("Failed to migrate database tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Database connection established")
|
log.Println("Database connection established")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
"xboard-go/internal/model"
|
"xboard-go/internal/model"
|
||||||
"xboard-go/internal/service"
|
"xboard-go/internal/service"
|
||||||
@@ -47,21 +49,62 @@ func AdminConfigSave(c *gin.Context) {
|
|||||||
Success(c, true)
|
Success(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetEmailTemplate list available email templates.
|
func AdminTestSendMail(c *gin.Context) {
|
||||||
func AdminGetEmailTemplate(c *gin.Context) {
|
config := service.LoadEmailConfig()
|
||||||
path := filepath.Join("resource", "views", "mail")
|
recipient := currentAdminEmail(c)
|
||||||
files, err := listFiles(path, "*")
|
if recipient == "" {
|
||||||
if err != nil {
|
recipient = config.SenderAddress()
|
||||||
Success(c, []string{})
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("[%s] SMTP test email", service.MustGetString("app_name", "XBoard"))
|
||||||
|
result := gin.H{
|
||||||
|
"email": recipient,
|
||||||
|
"subject": subject,
|
||||||
|
"template_name": config.TemplateName,
|
||||||
|
"config": config.DebugConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipient == "" {
|
||||||
|
result["error"] = "no test recipient is available"
|
||||||
|
Success(c, result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Success(c, files)
|
|
||||||
|
textBody := strings.Join([]string{
|
||||||
|
"This is a test email from SingBox-Gopanel.",
|
||||||
|
"",
|
||||||
|
"If you received this email, the current SMTP configuration is working.",
|
||||||
|
}, "\n")
|
||||||
|
htmlBody := strings.Join([]string{
|
||||||
|
"<h2>SMTP test email</h2>",
|
||||||
|
"<p>If you received this email, the current SMTP configuration is working.</p>",
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
if err := service.SendMail(config, service.EmailMessage{
|
||||||
|
To: []string{recipient},
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: textBody,
|
||||||
|
HTMLBody: htmlBody,
|
||||||
|
}); err != nil {
|
||||||
|
result["error"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminSetTelegramWebhook(c *gin.Context) {
|
||||||
|
SuccessMessage(c, "telegram webhook setup is not wired in the Go backend yet", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetEmailTemplate list available email templates.
|
||||||
|
func AdminGetEmailTemplate(c *gin.Context) {
|
||||||
|
Success(c, collectEmailTemplateNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetThemeTemplate list available themes.
|
// AdminGetThemeTemplate list available themes.
|
||||||
func AdminGetThemeTemplate(c *gin.Context) {
|
func AdminGetThemeTemplate(c *gin.Context) {
|
||||||
path := filepath.Join("public", "theme")
|
path := filepath.Join("public", "theme")
|
||||||
files, err := listFiles(path, "*")
|
files, err := listDirectoryEntries(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Success(c, []string{})
|
Success(c, []string{})
|
||||||
return
|
return
|
||||||
@@ -89,7 +132,7 @@ func getAllConfigMappings() gin.H {
|
|||||||
"site": gin.H{
|
"site": gin.H{
|
||||||
"logo": service.MustGetString("logo", ""),
|
"logo": service.MustGetString("logo", ""),
|
||||||
"force_https": service.MustGetInt("force_https", 0),
|
"force_https": service.MustGetInt("force_https", 0),
|
||||||
"stop_register": service.MustGetInt("stop_register", 0),
|
"stop_register": service.MustGetInt("stop_register", 0),
|
||||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||||
"app_description": service.MustGetString("app_description", "XBoard is best!"),
|
"app_description": service.MustGetString("app_description", "XBoard is best!"),
|
||||||
"app_url": service.MustGetString("app_url", ""),
|
"app_url": service.MustGetString("app_url", ""),
|
||||||
@@ -111,14 +154,14 @@ func getAllConfigMappings() gin.H {
|
|||||||
"show_info_to_server_enable": service.MustGetBool("show_info_to_server_enable", false),
|
"show_info_to_server_enable": service.MustGetBool("show_info_to_server_enable", false),
|
||||||
"show_protocol_to_server_enable": service.MustGetBool("show_protocol_to_server_enable", false),
|
"show_protocol_to_server_enable": service.MustGetBool("show_protocol_to_server_enable", false),
|
||||||
"default_remind_expire": service.MustGetBool("default_remind_expire", true),
|
"default_remind_expire": service.MustGetBool("default_remind_expire", true),
|
||||||
"default_remind_traffic" : service.MustGetBool("default_remind_traffic", true),
|
"default_remind_traffic": service.MustGetBool("default_remind_traffic", true),
|
||||||
"subscribe_path": service.MustGetString("subscribe_path", "s"),
|
"subscribe_path": service.MustGetString("subscribe_path", "s"),
|
||||||
},
|
},
|
||||||
"frontend": gin.H{
|
"frontend": gin.H{
|
||||||
"frontend_theme": service.MustGetString("frontend_theme", "Xboard"),
|
"frontend_theme": service.MustGetString("frontend_theme", "Xboard"),
|
||||||
"frontend_theme_sidebar": service.MustGetString("frontend_theme_sidebar", "light"),
|
"frontend_theme_sidebar": service.MustGetString("frontend_theme_sidebar", "light"),
|
||||||
"frontend_theme_header": service.MustGetString("frontend_theme_header", "dark"),
|
"frontend_theme_header": service.MustGetString("frontend_theme_header", "dark"),
|
||||||
"frontend_theme_color": service.MustGetString("frontend_theme_color", "default"),
|
"frontend_theme_color": service.MustGetString("frontend_theme_color", "default"),
|
||||||
"frontend_background_url": service.MustGetString("frontend_background_url", ""),
|
"frontend_background_url": service.MustGetString("frontend_background_url", ""),
|
||||||
},
|
},
|
||||||
"server": gin.H{
|
"server": gin.H{
|
||||||
@@ -130,18 +173,42 @@ func getAllConfigMappings() gin.H {
|
|||||||
"server_ws_url": service.MustGetString("server_ws_url", ""),
|
"server_ws_url": service.MustGetString("server_ws_url", ""),
|
||||||
},
|
},
|
||||||
"safe": gin.H{
|
"safe": gin.H{
|
||||||
"email_verify": service.MustGetBool("email_verify", false),
|
"email_verify": service.MustGetBool("email_verify", false),
|
||||||
"safe_mode_enable": service.MustGetBool("safe_mode_enable", false),
|
"safe_mode_enable": service.MustGetBool("safe_mode_enable", false),
|
||||||
"secure_path": service.GetAdminSecurePath(),
|
"secure_path": service.GetAdminSecurePath(),
|
||||||
"email_whitelist_enable": service.MustGetBool("email_whitelist_enable", false),
|
"email_whitelist_enable": service.MustGetBool("email_whitelist_enable", false),
|
||||||
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
||||||
"email_gmail_limit_enable": service.MustGetBool("email_gmail_limit_enable", false),
|
"email_gmail_limit_enable": service.MustGetBool("email_gmail_limit_enable", false),
|
||||||
"captcha_enable": service.MustGetBool("captcha_enable", false),
|
"captcha_enable": service.MustGetBool("captcha_enable", false),
|
||||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||||
"register_limit_by_ip_enable": service.MustGetBool("register_limit_by_ip_enable", false),
|
"register_limit_by_ip_enable": service.MustGetBool("register_limit_by_ip_enable", false),
|
||||||
"register_limit_count": service.MustGetInt("register_limit_count", 3),
|
"register_limit_count": service.MustGetInt("register_limit_count", 3),
|
||||||
"password_limit_enable": service.MustGetBool("password_limit_enable", true),
|
"password_limit_enable": service.MustGetBool("password_limit_enable", true),
|
||||||
},
|
},
|
||||||
|
"email": gin.H{
|
||||||
|
"email_template": service.MustGetString("email_template", "classic"),
|
||||||
|
"email_host": service.MustGetString("email_host", ""),
|
||||||
|
"email_port": service.MustGetInt("email_port", 465),
|
||||||
|
"email_username": service.MustGetString("email_username", ""),
|
||||||
|
"email_password": service.MustGetString("email_password", ""),
|
||||||
|
"email_encryption": service.MustGetString("email_encryption", ""),
|
||||||
|
"email_from_address": service.LoadEmailConfig().SenderAddress(),
|
||||||
|
"email_from_name": service.MustGetString("email_from_name", service.MustGetString("app_name", "XBoard")),
|
||||||
|
"remind_mail_enable": service.MustGetBool("remind_mail_enable", false),
|
||||||
|
},
|
||||||
|
"nebula": gin.H{
|
||||||
|
"nebula_theme_color": service.MustGetString("nebula_theme_color", "aurora"),
|
||||||
|
"nebula_hero_slogan": service.MustGetString("nebula_hero_slogan", ""),
|
||||||
|
"nebula_welcome_target": service.MustGetString("nebula_welcome_target", ""),
|
||||||
|
"nebula_register_title": service.MustGetString("nebula_register_title", ""),
|
||||||
|
"nebula_background_url": service.MustGetString("nebula_background_url", ""),
|
||||||
|
"nebula_metrics_base_url": service.MustGetString("nebula_metrics_base_url", ""),
|
||||||
|
"nebula_default_theme_mode": service.MustGetString("nebula_default_theme_mode", "system"),
|
||||||
|
"nebula_light_logo_url": service.MustGetString("nebula_light_logo_url", ""),
|
||||||
|
"nebula_dark_logo_url": service.MustGetString("nebula_dark_logo_url", ""),
|
||||||
|
"nebula_custom_html": service.MustGetString("nebula_custom_html", ""),
|
||||||
|
"nebula_static_cdn_url": service.MustGetString("nebula_static_cdn_url", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +222,7 @@ func saveSetting(name string, value any) {
|
|||||||
case int64:
|
case int64:
|
||||||
val = strconv.FormatInt(v, 10)
|
val = strconv.FormatInt(v, 10)
|
||||||
case float64:
|
case float64:
|
||||||
val = fmt.Sprintf("%f", v)
|
val = strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
case bool:
|
case bool:
|
||||||
if v {
|
if v {
|
||||||
val = "1"
|
val = "1"
|
||||||
@@ -166,19 +233,109 @@ func saveSetting(name string, value any) {
|
|||||||
// serialize complex types if needed
|
// serialize complex types if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
database.DB.Model(&model.Setting{}).Where("name = ?", name).Update("value", val)
|
result := database.DB.Model(&model.Setting{}).Where("name = ?", name).Update("value", val)
|
||||||
|
if result.Error == nil && result.RowsAffected > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setting := model.Setting{
|
||||||
|
Name: name,
|
||||||
|
Value: val,
|
||||||
|
}
|
||||||
|
if group := settingGroupName(name); group != "" {
|
||||||
|
setting.Group = &group
|
||||||
|
}
|
||||||
|
_ = database.DB.Where("name = ?", name).FirstOrCreate(&setting).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func listFiles(dir string, pattern string) ([]string, error) {
|
func listDirectoryEntries(dir string) ([]string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var files []string
|
var files []string
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
files = append(files, entry.Name())
|
||||||
files = append(files, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectEmailTemplateNames() []string {
|
||||||
|
names := map[string]struct{}{
|
||||||
|
"classic": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join("resource", "views", "mail"),
|
||||||
|
filepath.Join("reference", "Xboard", "resources", "views", "mail"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range candidates {
|
||||||
|
entries, err := listDirectoryEntries(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names[entry] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(names))
|
||||||
|
for name := range names {
|
||||||
|
result = append(result, name)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAdminEmail(c *gin.Context) string {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Select("email").Where("id = ?", intFromAny(userID)).First(&user).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingGroupName(name string) string {
|
||||||
|
switch name {
|
||||||
|
case "invite_force", "invite_commission", "invite_gen_limit", "invite_never_expire",
|
||||||
|
"commission_first_time_enable", "commission_auto_check_enable", "commission_withdraw_limit",
|
||||||
|
"commission_withdraw_method", "withdraw_close_enable", "commission_distribution_enable",
|
||||||
|
"commission_distribution_l1", "commission_distribution_l2", "commission_distribution_l3":
|
||||||
|
return "invite"
|
||||||
|
case "logo", "force_https", "stop_register", "app_name", "app_description", "app_url",
|
||||||
|
"subscribe_url", "try_out_plan_id", "try_out_hour", "tos_url", "currency",
|
||||||
|
"currency_symbol", "ticket_must_wait_reply":
|
||||||
|
return "site"
|
||||||
|
case "plan_change_enable", "reset_traffic_method", "surplus_enable", "new_order_event_id",
|
||||||
|
"renew_order_event_id", "change_order_event_id", "show_info_to_server_enable",
|
||||||
|
"show_protocol_to_server_enable", "default_remind_expire", "default_remind_traffic", "subscribe_path":
|
||||||
|
return "subscribe"
|
||||||
|
case "frontend_theme", "frontend_theme_sidebar", "frontend_theme_header", "frontend_theme_color", "frontend_background_url":
|
||||||
|
return "frontend"
|
||||||
|
case "server_token", "server_pull_interval", "server_push_interval", "device_limit_mode", "server_ws_enable", "server_ws_url":
|
||||||
|
return "server"
|
||||||
|
case "email_verify", "safe_mode_enable", "secure_path", "email_whitelist_enable",
|
||||||
|
"email_whitelist_suffix", "email_gmail_limit_enable", "captcha_enable", "captcha_type",
|
||||||
|
"register_limit_by_ip_enable", "register_limit_count", "password_limit_enable":
|
||||||
|
return "safe"
|
||||||
|
case "email_template", "email_host", "email_port", "email_username", "email_password", "email_encryption",
|
||||||
|
"email_from_address", "email_from_name", "email_from", "remind_mail_enable":
|
||||||
|
return "email"
|
||||||
|
case "nebula_theme_color", "nebula_hero_slogan", "nebula_welcome_target", "nebula_register_title",
|
||||||
|
"nebula_background_url", "nebula_metrics_base_url", "nebula_default_theme_mode",
|
||||||
|
"nebula_light_logo_url", "nebula_dark_logo_url", "nebula_custom_html", "nebula_static_cdn_url":
|
||||||
|
return "nebula"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
"xboard-go/internal/model"
|
"xboard-go/internal/model"
|
||||||
|
"xboard-go/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Stat Extra ---
|
// --- Stat Extra ---
|
||||||
|
|
||||||
func AdminGetStatUser(c *gin.Context) {
|
func AdminGetStatUser(c *gin.Context) {
|
||||||
userIdStr := c.Query("user_id")
|
params := getFetchParams(c)
|
||||||
|
userIdStr := firstString(c.Query("user_id"), params["user_id"], params["id"])
|
||||||
userId, _ := strconv.Atoi(userIdStr)
|
userId, _ := strconv.Atoi(userIdStr)
|
||||||
if userId == 0 {
|
if userId == 0 {
|
||||||
Fail(c, http.StatusBadRequest, "user_id is required")
|
Fail(c, http.StatusBadRequest, "user_id is required")
|
||||||
@@ -51,11 +55,11 @@ func AdminPaymentSave(c *gin.Context) {
|
|||||||
configJson, _ := marshalJSON(payload["config"], true)
|
configJson, _ := marshalJSON(payload["config"], true)
|
||||||
|
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"name": payload["name"],
|
"name": payload["name"],
|
||||||
"payment": payload["payment"],
|
"payment": payload["payment"],
|
||||||
"config": configJson,
|
"config": configJson,
|
||||||
"notify_domain": payload["notify_domain"],
|
"notify_domain": payload["notify_domain"],
|
||||||
"handling_fee_fixed": payload["handling_fee_fixed"],
|
"handling_fee_fixed": payload["handling_fee_fixed"],
|
||||||
"handling_fee_percent": payload["handling_fee_percent"],
|
"handling_fee_percent": payload["handling_fee_percent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,9 @@ func AdminPaymentSave(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AdminPaymentDrop(c *gin.Context) {
|
func AdminPaymentDrop(c *gin.Context) {
|
||||||
var payload struct{ ID int `json:"id"` }
|
var payload struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
c.ShouldBindJSON(&payload)
|
||||||
database.DB.Delete(&model.Payment{}, payload.ID)
|
database.DB.Delete(&model.Payment{}, payload.ID)
|
||||||
Success(c, true)
|
Success(c, true)
|
||||||
@@ -85,7 +91,9 @@ func AdminPaymentShow(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AdminPaymentSort(c *gin.Context) {
|
func AdminPaymentSort(c *gin.Context) {
|
||||||
var payload struct{ IDs []int `json:"ids"` }
|
var payload struct {
|
||||||
|
IDs []int `json:"ids"`
|
||||||
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
c.ShouldBindJSON(&payload)
|
||||||
for i, id := range payload.IDs {
|
for i, id := range payload.IDs {
|
||||||
database.DB.Model(&model.Payment{}).Where("id = ?", id).Update("sort", i)
|
database.DB.Model(&model.Payment{}).Where("id = ?", id).Update("sort", i)
|
||||||
@@ -108,10 +116,10 @@ func AdminNoticeSave(c *gin.Context) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"title": payload["title"],
|
"title": payload["title"],
|
||||||
"content": payload["content"],
|
"content": payload["content"],
|
||||||
"img_url": payload["img_url"],
|
"img_url": payload["img_url"],
|
||||||
"tags": payload["tags"],
|
"tags": payload["tags"],
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +133,9 @@ func AdminNoticeSave(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AdminNoticeDrop(c *gin.Context) {
|
func AdminNoticeDrop(c *gin.Context) {
|
||||||
var payload struct{ ID int `json:"id"` }
|
var payload struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
c.ShouldBindJSON(&payload)
|
||||||
database.DB.Delete(&model.Notice{}, payload.ID)
|
database.DB.Delete(&model.Notice{}, payload.ID)
|
||||||
Success(c, true)
|
Success(c, true)
|
||||||
@@ -148,18 +158,30 @@ func AdminNoticeSort(c *gin.Context) {
|
|||||||
// --- Order Extra ---
|
// --- Order Extra ---
|
||||||
|
|
||||||
func AdminOrderDetail(c *gin.Context) {
|
func AdminOrderDetail(c *gin.Context) {
|
||||||
var payload struct{ TradeNo string `json:"trade_no"` }
|
var payload struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
TradeNo string `json:"trade_no"`
|
||||||
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
c.ShouldBindJSON(&payload)
|
||||||
var order model.Order
|
var order model.Order
|
||||||
database.DB.Preload("Plan").Preload("Payment").Where("trade_no = ?", payload.TradeNo).First(&order)
|
query := database.DB.Preload("Plan").Preload("Payment")
|
||||||
|
if payload.TradeNo != "" {
|
||||||
|
query = query.Where("trade_no = ?", payload.TradeNo)
|
||||||
|
} else if payload.ID > 0 {
|
||||||
|
query = query.Where("id = ?", payload.ID)
|
||||||
|
}
|
||||||
|
if err := query.First(&order).Error; err != nil {
|
||||||
|
Fail(c, http.StatusNotFound, "order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
Success(c, normalizeOrder(order))
|
Success(c, normalizeOrder(order))
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminOrderAssign(c *gin.Context) {
|
func AdminOrderAssign(c *gin.Context) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PlanID int `json:"plan_id"`
|
PlanID int `json:"plan_id"`
|
||||||
Period string `json:"period"`
|
Period string `json:"period"`
|
||||||
}
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
c.ShouldBindJSON(&payload)
|
||||||
// Logic to manually create/assign an order and mark as paid
|
// Logic to manually create/assign an order and mark as paid
|
||||||
@@ -177,11 +199,22 @@ func AdminOrderUpdate(c *gin.Context) {
|
|||||||
// --- User Extra ---
|
// --- User Extra ---
|
||||||
|
|
||||||
func AdminUserResetSecret(c *gin.Context) {
|
func AdminUserResetSecret(c *gin.Context) {
|
||||||
var payload struct{ ID int `json:"id"` }
|
var payload struct {
|
||||||
c.ShouldBindJSON(&payload)
|
ID int `json:"id"`
|
||||||
newUuid := "new-uuid-placeholder" // Generate actual UUID
|
}
|
||||||
database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("uuid", newUuid)
|
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
|
||||||
Success(c, true)
|
Fail(c, http.StatusBadRequest, "user id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newUUID := uuid.NewString()
|
||||||
|
newToken := strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
if err := database.DB.Model(&model.User{}).
|
||||||
|
Where("id = ?", payload.ID).
|
||||||
|
Updates(map[string]any{"uuid": newUUID, "token": newToken}).Error; err != nil {
|
||||||
|
Fail(c, http.StatusInternalServerError, "failed to reset secret")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Success(c, newToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminUserSendMail(c *gin.Context) {
|
func AdminUserSendMail(c *gin.Context) {
|
||||||
@@ -190,8 +223,49 @@ func AdminUserSendMail(c *gin.Context) {
|
|||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
c.ShouldBindJSON(&payload)
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
// Logic to send email
|
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.UserID <= 0 {
|
||||||
|
Fail(c, http.StatusBadRequest, "user id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.Subject) == "" {
|
||||||
|
Fail(c, http.StatusBadRequest, "subject is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.Content) == "" {
|
||||||
|
Fail(c, http.StatusBadRequest, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Select("email").Where("id = ?", payload.UserID).First(&user).Error; err != nil {
|
||||||
|
Fail(c, http.StatusBadRequest, "user does not exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textBody := payload.Content
|
||||||
|
htmlBody := ""
|
||||||
|
if looksLikeHTML(payload.Content) {
|
||||||
|
htmlBody = payload.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.SendMailWithCurrentSettings(service.EmailMessage{
|
||||||
|
To: []string{user.Email},
|
||||||
|
Subject: payload.Subject,
|
||||||
|
TextBody: textBody,
|
||||||
|
HTMLBody: htmlBody,
|
||||||
|
}); err != nil {
|
||||||
|
Fail(c, http.StatusInternalServerError, "failed to send email: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Success(c, true)
|
Success(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
return strings.Contains(value, "<") && strings.Contains(value, ">")
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
// AdminGiftCardFetch handles fetching of gift card templates and batch info.
|
// AdminGiftCardFetch handles fetching of gift card templates and batch info.
|
||||||
func AdminGiftCardFetch(c *gin.Context) {
|
func AdminGiftCardFetch(c *gin.Context) {
|
||||||
id := c.Query("id")
|
params := getFetchParams(c)
|
||||||
|
id := params["id"]
|
||||||
if id != "" {
|
if id != "" {
|
||||||
var template model.GiftCardTemplate
|
var template model.GiftCardTemplate
|
||||||
if err := database.DB.Where("id = ?", id).First(&template).Error; err != nil {
|
if err := database.DB.Where("id = ?", id).First(&template).Error; err != nil {
|
||||||
@@ -100,6 +101,21 @@ func AdminGiftCardGenerate(c *gin.Context) {
|
|||||||
Success(c, gin.H{"batch_id": batchID})
|
Success(c, gin.H{"batch_id": batchID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminGiftCardDeleteTemplate(c *gin.Context) {
|
||||||
|
var payload struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
|
||||||
|
Fail(c, http.StatusBadRequest, "template id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := database.DB.Delete(&model.GiftCardTemplate{}, payload.ID).Error; err != nil {
|
||||||
|
Fail(c, http.StatusInternalServerError, "failed to delete template")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Success(c, true)
|
||||||
|
}
|
||||||
|
|
||||||
func generateGiftCode(prefix string) string {
|
func generateGiftCode(prefix string) string {
|
||||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
hash := md5.Sum([]byte(fmt.Sprintf("%d%d", time.Now().UnixNano(), r.Int63())))
|
hash := md5.Sum([]byte(fmt.Sprintf("%d%d", time.Now().UnixNano(), r.Int63())))
|
||||||
|
|||||||
37
internal/handler/admin_plugin_page.go
Normal file
37
internal/handler/admin_plugin_page.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"xboard-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminPluginPageViewData struct {
|
||||||
|
Title string
|
||||||
|
Kind string
|
||||||
|
KindLabel string
|
||||||
|
SecurePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminPluginPanelPage(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
labels := map[string]string{
|
||||||
|
"realname": "实名认证",
|
||||||
|
"online-devices": "在线 IP 统计",
|
||||||
|
"ipv6-subscription": "IPv6 子账号",
|
||||||
|
}
|
||||||
|
label, ok := labels[kind]
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusNotFound, "plugin panel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_plugin_panel.html"), adminPluginPageViewData{
|
||||||
|
Title: service.MustGetString("app_name", "XBoard") + " - " + label,
|
||||||
|
Kind: kind,
|
||||||
|
KindLabel: label,
|
||||||
|
SecurePath: service.GetAdminSecurePath(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ func AdminDashboardSummary(c *gin.Context) {
|
|||||||
var totalOrders int64
|
var totalOrders int64
|
||||||
var pendingOrders int64
|
var pendingOrders int64
|
||||||
var pendingTickets int64
|
var pendingTickets int64
|
||||||
|
var commissionPendingTotal int64
|
||||||
var onlineUsers int64
|
var onlineUsers int64
|
||||||
var onlineNodes int64
|
var onlineNodes int64
|
||||||
var onlineDevices int64
|
var onlineDevices int64
|
||||||
@@ -30,6 +31,10 @@ func AdminDashboardSummary(c *gin.Context) {
|
|||||||
database.DB.Model(&model.User{}).Count(&totalUsers)
|
database.DB.Model(&model.User{}).Count(&totalUsers)
|
||||||
database.DB.Model(&model.Order{}).Count(&totalOrders)
|
database.DB.Model(&model.Order{}).Count(&totalOrders)
|
||||||
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
|
database.DB.Model(&model.Order{}).Where("status = ?", 0).Count(&pendingOrders)
|
||||||
|
database.DB.Model(&model.Order{}).
|
||||||
|
Where("status = ? AND commission_status = ?", 3, 0).
|
||||||
|
Select("COALESCE(SUM(commission_balance), 0)").
|
||||||
|
Scan(&commissionPendingTotal)
|
||||||
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
|
database.DB.Model(&model.Ticket{}).Where("status = ?", 0).Count(&pendingTickets)
|
||||||
database.DB.Model(&model.Server{}).Where("show = ?", true).Count(&onlineNodes) // Simplified online check
|
database.DB.Model(&model.Server{}).Where("show = ?", true).Count(&onlineNodes) // Simplified online check
|
||||||
|
|
||||||
@@ -75,25 +80,28 @@ func AdminDashboardSummary(c *gin.Context) {
|
|||||||
userGrowth := calculateGrowth(currentMonthNewUsers, lastMonthNewUsers)
|
userGrowth := calculateGrowth(currentMonthNewUsers, lastMonthNewUsers)
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"server_time": now,
|
"server_time": now,
|
||||||
"todayIncome": todayIncome,
|
"todayIncome": todayIncome,
|
||||||
"dayIncomeGrowth": dayIncomeGrowth,
|
"dayIncomeGrowth": dayIncomeGrowth,
|
||||||
"currentMonthIncome": currentMonthIncome,
|
"currentMonthIncome": currentMonthIncome,
|
||||||
"lastMonthIncome": lastMonthIncome,
|
"lastMonthIncome": lastMonthIncome,
|
||||||
"monthIncomeGrowth": monthIncomeGrowth,
|
"monthIncomeGrowth": monthIncomeGrowth,
|
||||||
"currentMonthNewUsers": currentMonthNewUsers,
|
"totalOrders": totalOrders,
|
||||||
"totalUsers": totalUsers,
|
"pendingOrders": pendingOrders,
|
||||||
"activeUsers": totalUsers, // Placeholder for valid subscription count
|
"currentMonthNewUsers": currentMonthNewUsers,
|
||||||
"userGrowth": userGrowth,
|
"totalUsers": totalUsers,
|
||||||
"onlineUsers": onlineUsers,
|
"activeUsers": totalUsers, // Placeholder for valid subscription count
|
||||||
"onlineDevices": onlineDevices,
|
"userGrowth": userGrowth,
|
||||||
"ticketPendingTotal": pendingTickets,
|
"commissionPendingTotal": commissionPendingTotal,
|
||||||
"onlineNodes": onlineNodes,
|
"onlineUsers": onlineUsers,
|
||||||
"todayTraffic": todayTraffic,
|
"onlineDevices": onlineDevices,
|
||||||
"monthTraffic": monthTraffic,
|
"ticketPendingTotal": pendingTickets,
|
||||||
"totalTraffic": totalTraffic,
|
"onlineNodes": onlineNodes,
|
||||||
"secure_path": service.GetAdminSecurePath(),
|
"todayTraffic": todayTraffic,
|
||||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
"monthTraffic": monthTraffic,
|
||||||
|
"totalTraffic": totalTraffic,
|
||||||
|
"secure_path": service.GetAdminSecurePath(),
|
||||||
|
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +115,6 @@ func calculateGrowth(current, previous int64) float64 {
|
|||||||
return float64(current-previous) / float64(previous) * 100.0
|
return float64(current-previous) / float64(previous) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func AdminPlansFetch(c *gin.Context) {
|
func AdminPlansFetch(c *gin.Context) {
|
||||||
var plans []model.Plan
|
var plans []model.Plan
|
||||||
if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil {
|
if err := database.DB.Order("sort ASC, id DESC").Find(&plans).Error; err != nil {
|
||||||
@@ -248,11 +255,10 @@ func AdminPlanSort(c *gin.Context) {
|
|||||||
Success(c, true)
|
Success(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func AdminOrdersFetch(c *gin.Context) {
|
func AdminOrdersFetch(c *gin.Context) {
|
||||||
params := getFetchParams(c)
|
params := getFetchParams(c)
|
||||||
page := parsePositiveInt(params["page"], 1)
|
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
|
||||||
perPage := parsePositiveInt(params["per_page"], 50)
|
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
|
||||||
keyword := strings.TrimSpace(params["keyword"])
|
keyword := strings.TrimSpace(params["keyword"])
|
||||||
statusFilter := strings.TrimSpace(params["status"])
|
statusFilter := strings.TrimSpace(params["status"])
|
||||||
|
|
||||||
@@ -277,15 +283,28 @@ func AdminOrdersFetch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
|
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
|
||||||
|
inviteEmails := loadUserEmailMap(extractOrderInviteUserIDs(orders))
|
||||||
items := make([]gin.H, 0, len(orders))
|
items := make([]gin.H, 0, len(orders))
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
item := normalizeOrder(order)
|
item := normalizeOrder(order)
|
||||||
item["user_email"] = userEmails[order.UserID]
|
item["user_email"] = userEmails[order.UserID]
|
||||||
|
item["user"] = gin.H{
|
||||||
|
"id": order.UserID,
|
||||||
|
"email": userEmails[order.UserID],
|
||||||
|
}
|
||||||
|
if order.InviteUserID != nil {
|
||||||
|
item["invite_user"] = gin.H{
|
||||||
|
"id": *order.InviteUserID,
|
||||||
|
"email": inviteEmails[*order.InviteUserID],
|
||||||
|
}
|
||||||
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"list": items,
|
"list": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
"filters": gin.H{
|
"filters": gin.H{
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
"status": statusFilter,
|
"status": statusFilter,
|
||||||
@@ -388,7 +407,6 @@ func AdminCouponDrop(c *gin.Context) {
|
|||||||
Success(c, true)
|
Success(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func AdminOrderPaid(c *gin.Context) {
|
func AdminOrderPaid(c *gin.Context) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
TradeNo string `json:"trade_no"`
|
TradeNo string `json:"trade_no"`
|
||||||
@@ -421,23 +439,17 @@ func AdminOrderPaid(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
updates := map[string]any{
|
||||||
var user model.User
|
"plan_id": order.PlanID,
|
||||||
if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil {
|
"updated_at": now,
|
||||||
// Calculate expiration and traffic
|
}
|
||||||
// Simplified logic: set plan and transfer_enable
|
if order.Plan != nil {
|
||||||
updates := map[string]any{
|
updates["transfer_enable"] = order.Plan.TransferEnable
|
||||||
"plan_id": order.PlanID,
|
}
|
||||||
"updated_at": now,
|
if err := tx.Model(&model.User{}).Where("id = ?", order.UserID).Updates(updates).Error; err != nil {
|
||||||
}
|
tx.Rollback()
|
||||||
if order.Plan != nil {
|
Fail(c, http.StatusInternalServerError, "failed to update user")
|
||||||
updates["transfer_enable"] = order.Plan.TransferEnable
|
return
|
||||||
}
|
|
||||||
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()
|
tx.Commit()
|
||||||
@@ -527,19 +539,19 @@ func AdminUserUpdate(c *gin.Context) {
|
|||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"email": payload["email"],
|
"email": payload["email"],
|
||||||
"password": payload["password"],
|
"password": payload["password"],
|
||||||
"balance": payload["balance"],
|
"balance": payload["balance"],
|
||||||
"commission_type": payload["commission_type"],
|
"commission_type": payload["commission_type"],
|
||||||
"commission_rate": payload["commission_rate"],
|
"commission_rate": payload["commission_rate"],
|
||||||
"commission_balance": payload["commission_balance"],
|
"commission_balance": payload["commission_balance"],
|
||||||
"group_id": payload["group_id"],
|
"group_id": payload["group_id"],
|
||||||
"plan_id": payload["plan_id"],
|
"plan_id": payload["plan_id"],
|
||||||
"speed_limit": payload["speed_limit"],
|
"speed_limit": payload["speed_limit"],
|
||||||
"device_limit": payload["device_limit"],
|
"device_limit": payload["device_limit"],
|
||||||
"expired_at": payload["expired_at"],
|
"expired_at": payload["expired_at"],
|
||||||
"remarks": payload["remarks"],
|
"remarks": payload["remarks"],
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove nil values to avoid overwriting with defaults if not provided
|
// Remove nil values to avoid overwriting with defaults if not provided
|
||||||
@@ -564,8 +576,8 @@ func AdminUserUpdate(c *gin.Context) {
|
|||||||
|
|
||||||
func AdminUsersFetch(c *gin.Context) {
|
func AdminUsersFetch(c *gin.Context) {
|
||||||
params := getFetchParams(c)
|
params := getFetchParams(c)
|
||||||
page := parsePositiveInt(params["page"], 1)
|
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
|
||||||
perPage := parsePositiveInt(params["per_page"], 50)
|
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
|
||||||
keyword := strings.TrimSpace(params["keyword"])
|
keyword := strings.TrimSpace(params["keyword"])
|
||||||
|
|
||||||
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
|
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
|
||||||
@@ -591,6 +603,28 @@ func AdminUsersFetch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
deviceMap := service.GetUsersDevices(userIDs)
|
deviceMap := service.GetUsersDevices(userIDs)
|
||||||
|
|
||||||
|
realnameStatusByUserID := make(map[int]string, len(userIDs))
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var records []model.RealNameAuth
|
||||||
|
if err := database.DB.Select("user_id", "status").Where("user_id IN ?", userIDs).Find(&records).Error; err == nil {
|
||||||
|
for _, record := range records {
|
||||||
|
realnameStatusByUserID[int(record.UserID)] = record.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shadowByParentID := make(map[int]model.User, len(userIDs))
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var shadowUsers []model.User
|
||||||
|
if err := database.DB.Select("id", "parent_id", "email").Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
|
||||||
|
for _, shadow := range shadowUsers {
|
||||||
|
if shadow.ParentID != nil {
|
||||||
|
shadowByParentID[*shadow.ParentID] = shadow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
groupNames := loadServerGroupNameMap()
|
groupNames := loadServerGroupNameMap()
|
||||||
items := make([]gin.H, 0, len(users))
|
items := make([]gin.H, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
@@ -599,37 +633,70 @@ func AdminUsersFetch(c *gin.Context) {
|
|||||||
onlineIP = strings.Join(ips, ", ")
|
onlineIP = strings.Join(ips, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
realnameStatus := realnameStatusByUserID[user.ID]
|
||||||
|
if realnameStatus == "" {
|
||||||
|
realnameStatus = "unverified"
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6Shadow, hasIPv6Shadow := shadowByParentID[user.ID]
|
||||||
|
ipv6ShadowID := 0
|
||||||
|
if hasIPv6Shadow {
|
||||||
|
ipv6ShadowID = ipv6Shadow.ID
|
||||||
|
}
|
||||||
|
|
||||||
items = append(items, gin.H{
|
items = append(items, gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"balance": user.Balance,
|
"parent_id": intValue(user.ParentID),
|
||||||
"group_id": intValue(user.GroupID),
|
"is_shadow_user": user.ParentID != nil,
|
||||||
"group_name": groupNames[intFromPointer(user.GroupID)],
|
"balance": user.Balance,
|
||||||
"plan_id": intValue(user.PlanID),
|
"uuid": user.UUID,
|
||||||
"plan_name": planName(user.Plan),
|
"token": user.Token,
|
||||||
"transfer_enable": user.TransferEnable,
|
"group_id": intValue(user.GroupID),
|
||||||
"u": user.U,
|
"group_name": groupNames[intFromPointer(user.GroupID)],
|
||||||
"d": user.D,
|
"group": gin.H{
|
||||||
"banned": user.Banned,
|
"id": intValue(user.GroupID),
|
||||||
"is_admin": user.IsAdmin,
|
"name": groupNames[intFromPointer(user.GroupID)],
|
||||||
"is_staff": user.IsStaff,
|
},
|
||||||
"device_limit": intValue(user.DeviceLimit),
|
"plan_id": intValue(user.PlanID),
|
||||||
"online_count": intValue(user.OnlineCount),
|
"plan_name": planName(user.Plan),
|
||||||
"expired_at": int64Value(user.ExpiredAt),
|
"plan": gin.H{
|
||||||
"last_login_at": int64Value(user.LastLoginAt),
|
"id": intValue(user.PlanID),
|
||||||
"last_login_ip": user.LastLoginIP,
|
"name": planName(user.Plan),
|
||||||
"online_ip": onlineIP,
|
},
|
||||||
"created_at": user.CreatedAt,
|
"transfer_enable": user.TransferEnable,
|
||||||
"updated_at": user.UpdatedAt,
|
"u": user.U,
|
||||||
"remarks": stringValue(user.Remarks),
|
"d": user.D,
|
||||||
"commission_type": user.CommissionType,
|
"total_used": user.U + user.D,
|
||||||
"commission_rate": intValue(user.CommissionRate),
|
"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),
|
||||||
|
"next_reset_at": int64Value(user.NextResetAt),
|
||||||
|
"last_login_at": int64Value(user.LastLoginAt),
|
||||||
|
"last_login_ip": user.LastLoginIP,
|
||||||
|
"online_ip": onlineIP,
|
||||||
|
"online_ip_count": len(deviceMap[user.ID]),
|
||||||
|
"realname_status": realnameStatus,
|
||||||
|
"realname_label": realNameStatusLabel(realnameStatus),
|
||||||
|
"ipv6_shadow_id": ipv6ShadowID,
|
||||||
|
"ipv6_shadow_email": firstString(ipv6Shadow.Email, service.IPv6ShadowEmail(user.Email)),
|
||||||
|
"ipv6_enabled": hasIPv6Shadow,
|
||||||
|
"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,
|
"commission_balance": user.CommissionBalance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Success(c, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"list": items,
|
"list": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
"filters": gin.H{
|
"filters": gin.H{
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
},
|
},
|
||||||
@@ -689,7 +756,9 @@ func AdminTicketsFetch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"list": items,
|
"list": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
"filters": gin.H{
|
"filters": gin.H{
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
},
|
},
|
||||||
@@ -746,6 +815,16 @@ func extractOrderUserIDs(orders []model.Order) []int {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractOrderInviteUserIDs(orders []model.Order) []int {
|
||||||
|
ids := make([]int, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
if order.InviteUserID != nil {
|
||||||
|
ids = append(ids, *order.InviteUserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func extractTicketUserIDs(tickets []model.Ticket) []int {
|
func extractTicketUserIDs(tickets []model.Ticket) []int {
|
||||||
ids := make([]int, 0, len(tickets))
|
ids := make([]int, 0, len(tickets))
|
||||||
for _, ticket := range tickets {
|
for _, ticket := range tickets {
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
|
|||||||
if parentServer, ok := servers[*server.ParentID]; ok {
|
if parentServer, ok := servers[*server.ParentID]; ok {
|
||||||
parent = gin.H{
|
parent = gin.H{
|
||||||
"id": parentServer.ID,
|
"id": parentServer.ID,
|
||||||
|
"code": stringValue(parentServer.Code),
|
||||||
"type": parentServer.Type,
|
"type": parentServer.Type,
|
||||||
"name": parentServer.Name,
|
"name": parentServer.Name,
|
||||||
"host": parentServer.Host,
|
"host": parentServer.Host,
|
||||||
@@ -581,11 +582,11 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
|
|||||||
isOnline = 2
|
isOnline = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
availableStatus := "offline"
|
availableStatus := 0
|
||||||
if isOnline == 1 {
|
if isOnline == 1 {
|
||||||
availableStatus = "online_no_push"
|
availableStatus = 1
|
||||||
} else if isOnline == 2 {
|
} else if isOnline == 2 {
|
||||||
availableStatus = "online"
|
availableStatus = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
hasChildren := false
|
hasChildren := false
|
||||||
@@ -862,4 +863,3 @@ func isAllowedRouteAction(action string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,18 @@ func AdminGetTrafficRank(c *gin.Context) {
|
|||||||
if endTime == 0 {
|
if endTime == 0 {
|
||||||
endTime = time.Now().Unix()
|
endTime = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
periodDuration := endTime - startTime
|
||||||
|
if periodDuration <= 0 {
|
||||||
|
periodDuration = 86400
|
||||||
|
}
|
||||||
|
previousStart := startTime - periodDuration
|
||||||
|
previousEnd := startTime
|
||||||
|
|
||||||
var result []gin.H
|
var result []gin.H
|
||||||
if rankType == "user" {
|
if rankType == "user" {
|
||||||
type userRank struct {
|
type userRank struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Value int64 `json:"value"`
|
Value int64 `json:"value"`
|
||||||
Email string `json:"name"`
|
|
||||||
}
|
}
|
||||||
var ranks []userRank
|
var ranks []userRank
|
||||||
database.DB.Model(&model.StatUser{}).
|
database.DB.Model(&model.StatUser{}).
|
||||||
@@ -47,11 +52,15 @@ func AdminGetTrafficRank(c *gin.Context) {
|
|||||||
userIDs = append(userIDs, r.ID)
|
userIDs = append(userIDs, r.ID)
|
||||||
}
|
}
|
||||||
userEmails := loadUserEmailMap(userIDs)
|
userEmails := loadUserEmailMap(userIDs)
|
||||||
|
previousValues := loadUserPreviousTrafficMap(userIDs, previousStart, previousEnd)
|
||||||
for _, r := range ranks {
|
for _, r := range ranks {
|
||||||
|
previousValue := previousValues[r.ID]
|
||||||
result = append(result, gin.H{
|
result = append(result, gin.H{
|
||||||
"id": fmt.Sprintf("%d", r.ID),
|
"id": fmt.Sprintf("%d", r.ID),
|
||||||
"name": userEmails[r.ID],
|
"name": userEmails[r.ID],
|
||||||
"value": r.Value,
|
"value": r.Value,
|
||||||
|
"previousValue": previousValue,
|
||||||
|
"change": calculateGrowth(r.Value, previousValue),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -75,11 +84,15 @@ func AdminGetTrafficRank(c *gin.Context) {
|
|||||||
nodeIDs = append(nodeIDs, r.ID)
|
nodeIDs = append(nodeIDs, r.ID)
|
||||||
}
|
}
|
||||||
nodeNames := loadNodeNameMap(nodeIDs)
|
nodeNames := loadNodeNameMap(nodeIDs)
|
||||||
|
previousValues := loadNodePreviousTrafficMap(nodeIDs, previousStart, previousEnd)
|
||||||
for _, r := range ranks {
|
for _, r := range ranks {
|
||||||
|
previousValue := previousValues[r.ID]
|
||||||
result = append(result, gin.H{
|
result = append(result, gin.H{
|
||||||
"id": fmt.Sprintf("%d", r.ID),
|
"id": fmt.Sprintf("%d", r.ID),
|
||||||
"name": nodeNames[r.ID],
|
"name": nodeNames[r.ID],
|
||||||
"value": r.Value,
|
"value": r.Value,
|
||||||
|
"previousValue": previousValue,
|
||||||
|
"change": calculateGrowth(r.Value, previousValue),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +129,10 @@ func AdminGetOrderStats(c *gin.Context) {
|
|||||||
Find(&stats)
|
Find(&stats)
|
||||||
|
|
||||||
var list []gin.H
|
var list []gin.H
|
||||||
|
var paidTotal int64
|
||||||
|
var paidCount int64
|
||||||
|
var commissionTotal int64
|
||||||
|
var commissionCount int64
|
||||||
for _, s := range stats {
|
for _, s := range stats {
|
||||||
dateStr := time.Unix(s.RecordAt, 0).Format("2006-01-02")
|
dateStr := time.Unix(s.RecordAt, 0).Format("2006-01-02")
|
||||||
item := gin.H{
|
item := gin.H{
|
||||||
@@ -127,16 +144,80 @@ func AdminGetOrderStats(c *gin.Context) {
|
|||||||
item["paid_total"] = s.PaidTotal
|
item["paid_total"] = s.PaidTotal
|
||||||
item["paid_count"] = s.PaidCount
|
item["paid_count"] = s.PaidCount
|
||||||
item["commission_total"] = s.CommissionTotal
|
item["commission_total"] = s.CommissionTotal
|
||||||
|
item["commission_count"] = s.CommissionCount
|
||||||
}
|
}
|
||||||
|
paidTotal += s.PaidTotal
|
||||||
|
paidCount += int64(s.PaidCount)
|
||||||
|
commissionTotal += s.CommissionTotal
|
||||||
|
commissionCount += int64(s.CommissionCount)
|
||||||
list = append(list, item)
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
avgPaidAmount := int64(0)
|
||||||
|
if paidCount > 0 {
|
||||||
|
avgPaidAmount = paidTotal / paidCount
|
||||||
|
}
|
||||||
|
commissionRate := 0.0
|
||||||
|
if paidTotal > 0 {
|
||||||
|
commissionRate = float64(commissionTotal) / float64(paidTotal) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"list": list,
|
"list": list,
|
||||||
|
"summary": gin.H{
|
||||||
|
"start_date": time.Unix(startDate, 0).Format("2006-01-02"),
|
||||||
|
"end_date": time.Unix(endDate, 0).Format("2006-01-02"),
|
||||||
|
"paid_total": paidTotal,
|
||||||
|
"paid_count": paidCount,
|
||||||
|
"avg_paid_amount": avgPaidAmount,
|
||||||
|
"commission_total": commissionTotal,
|
||||||
|
"commission_count": commissionCount,
|
||||||
|
"commission_rate": commissionRate,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadUserPreviousTrafficMap(ids []int, startTime int64, endTime int64) map[int]int64 {
|
||||||
|
result := make(map[int]int64)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
type userRank struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
var ranks []userRank
|
||||||
|
database.DB.Model(&model.StatUser{}).
|
||||||
|
Select("user_id as id, SUM(u + d) as value").
|
||||||
|
Where("user_id IN ? AND record_at >= ? AND record_at <= ?", ids, startTime, endTime).
|
||||||
|
Group("user_id").
|
||||||
|
Scan(&ranks)
|
||||||
|
for _, rank := range ranks {
|
||||||
|
result[rank.ID] = rank.Value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNodePreviousTrafficMap(ids []int, startTime int64, endTime int64) map[int]int64 {
|
||||||
|
result := make(map[int]int64)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
type nodeRank struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
var ranks []nodeRank
|
||||||
|
database.DB.Model(&model.StatServer{}).
|
||||||
|
Select("server_id as id, SUM(u + d) as value").
|
||||||
|
Where("server_id IN ? AND record_at >= ? AND record_at <= ?", ids, startTime, endTime).
|
||||||
|
Group("server_id").
|
||||||
|
Scan(&ranks)
|
||||||
|
for _, rank := range ranks {
|
||||||
|
result[rank.ID] = rank.Value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func loadNodeNameMap(ids []int) map[int]string {
|
func loadNodeNameMap(ids []int) map[int]string {
|
||||||
result := make(map[int]string)
|
result := make(map[int]string)
|
||||||
|
|||||||
@@ -99,10 +99,6 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && user.PlanID != nil {
|
|
||||||
service.SyncIPv6ShadowAccount(&user)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||||
@@ -166,7 +162,29 @@ func SendEmailVerify(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
service.StoreEmailVerifyCode(strings.ToLower(strings.TrimSpace(req.Email)), code, 10*time.Minute)
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
subject := fmt.Sprintf("[%s] Email verification code", service.MustGetString("app_name", "XBoard"))
|
||||||
|
textBody := strings.Join([]string{
|
||||||
|
"Your email verification code is:",
|
||||||
|
code,
|
||||||
|
"",
|
||||||
|
"This code will expire in 10 minutes.",
|
||||||
|
}, "\n")
|
||||||
|
htmlBody := fmt.Sprintf(
|
||||||
|
"<h2>Email verification</h2><p>Your verification code is <strong style=\"font-size:24px;letter-spacing:4px;\">%s</strong>.</p><p>This code will expire in 10 minutes.</p>",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
if err := service.SendMailWithCurrentSettings(service.EmailMessage{
|
||||||
|
To: []string{email},
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: textBody,
|
||||||
|
HTMLBody: htmlBody,
|
||||||
|
}); err != nil {
|
||||||
|
Fail(c, http.StatusInternalServerError, "failed to send verification email: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service.StoreEmailVerifyCode(email, code, 10*time.Minute)
|
||||||
SuccessMessage(c, "email verify code generated", gin.H{
|
SuccessMessage(c, "email verify code generated", gin.H{
|
||||||
"email": req.Email,
|
"email": req.Email,
|
||||||
"debug_code": code,
|
"debug_code": code,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
"xboard-go/internal/model"
|
"xboard-go/internal/model"
|
||||||
"xboard-go/internal/service"
|
"xboard-go/internal/service"
|
||||||
|
"xboard-go/pkg/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
|||||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
|
||||||
query := database.DB.Model(&model.User{}).Order("id DESC")
|
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
|
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
|
||||||
}
|
}
|
||||||
@@ -35,33 +36,66 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
|||||||
userIDs = append(userIDs, user.ID)
|
userIDs = append(userIDs, user.ID)
|
||||||
}
|
}
|
||||||
devices := service.GetUsersDevices(userIDs)
|
devices := service.GetUsersDevices(userIDs)
|
||||||
|
deviceMetaByUserID := make(map[int]struct {
|
||||||
|
LastSeenAt int64
|
||||||
|
Count int
|
||||||
|
}, len(userIDs))
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var rows []struct {
|
||||||
|
UserID int
|
||||||
|
LastSeenAt int64
|
||||||
|
IPCount int
|
||||||
|
}
|
||||||
|
_ = database.DB.Table("v2_user_online_devices").
|
||||||
|
Select("user_id, MAX(last_seen_at) AS last_seen_at, COUNT(DISTINCT ip) AS ip_count").
|
||||||
|
Where("user_id IN ? AND expires_at > ?", userIDs, time.Now().Unix()).
|
||||||
|
Group("user_id").
|
||||||
|
Scan(&rows).Error
|
||||||
|
for _, row := range rows {
|
||||||
|
deviceMetaByUserID[row.UserID] = struct {
|
||||||
|
LastSeenAt int64
|
||||||
|
Count int
|
||||||
|
}{LastSeenAt: row.LastSeenAt, Count: row.IPCount}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list := make([]gin.H, 0, len(users))
|
list := make([]gin.H, 0, len(users))
|
||||||
usersWithOnlineIP := 0
|
usersWithOnlineIP := 0
|
||||||
totalOnlineIPs := 0
|
totalOnlineIPs := 0
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
subscriptionName := "No subscription"
|
ips := devices[user.ID]
|
||||||
if user.PlanID != nil {
|
meta := deviceMetaByUserID[user.ID]
|
||||||
var plan model.Plan
|
onlineCount := len(ips)
|
||||||
if database.DB.First(&plan, *user.PlanID).Error == nil {
|
if meta.Count > onlineCount {
|
||||||
subscriptionName = plan.Name
|
onlineCount = meta.Count
|
||||||
}
|
}
|
||||||
|
if onlineCount > 0 {
|
||||||
|
usersWithOnlineIP++
|
||||||
|
totalOnlineIPs += onlineCount
|
||||||
}
|
}
|
||||||
|
|
||||||
ips := devices[user.ID]
|
lastOnlineText := formatTimeValue(user.LastOnlineAt)
|
||||||
if len(ips) > 0 {
|
if meta.LastSeenAt > 0 {
|
||||||
usersWithOnlineIP++
|
lastOnlineText = formatUnixValue(meta.LastSeenAt)
|
||||||
totalOnlineIPs += len(ips)
|
}
|
||||||
|
|
||||||
|
status := "offline"
|
||||||
|
statusLabel := "Offline"
|
||||||
|
if onlineCount > 0 {
|
||||||
|
status = "online"
|
||||||
|
statusLabel = "Online"
|
||||||
}
|
}
|
||||||
|
|
||||||
list = append(list, gin.H{
|
list = append(list, gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"subscription_name": subscriptionName,
|
"subscription_name": planName(user.Plan),
|
||||||
"online_count": len(ips),
|
"online_count": onlineCount,
|
||||||
"online_devices": ips,
|
"online_devices": ips,
|
||||||
"last_online_text": formatTimeValue(user.LastOnlineAt),
|
"last_online_text": lastOnlineText,
|
||||||
"created_text": formatUnixValue(user.CreatedAt),
|
"created_text": formatUnixValue(user.CreatedAt),
|
||||||
|
"status": status,
|
||||||
|
"status_label": statusLabel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +120,170 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminIPv6SubscriptionUsers(c *gin.Context) {
|
||||||
|
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||||
|
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||||
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
|
||||||
|
suffix := service.GetPluginConfigString(service.PluginUserAddIPv6, "email_suffix", "-ipv6")
|
||||||
|
shadowPattern := "%" + suffix + "@%"
|
||||||
|
|
||||||
|
query := database.DB.Model(&model.User{}).
|
||||||
|
Preload("Plan").
|
||||||
|
Where("email NOT LIKE ?", shadowPattern).
|
||||||
|
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, 500, "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, 500, "failed to fetch users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]int, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
userIDs = append(userIDs, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionByUserID := make(map[int]model.UserIPv6Subscription, len(userIDs))
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var rows []model.UserIPv6Subscription
|
||||||
|
if err := database.DB.Where("user_id IN ?", userIDs).Find(&rows).Error; err == nil {
|
||||||
|
for _, row := range rows {
|
||||||
|
subscriptionByUserID[row.UserID] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shadowByParentID := make(map[int]model.User, len(userIDs))
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
var shadowUsers []model.User
|
||||||
|
if err := database.DB.Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
|
||||||
|
for _, shadow := range shadowUsers {
|
||||||
|
if shadow.ParentID != nil {
|
||||||
|
shadowByParentID[*shadow.ParentID] = shadow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]gin.H, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
subscription, hasSubscription := subscriptionByUserID[user.ID]
|
||||||
|
shadowUser, hasShadowUser := shadowByParentID[user.ID]
|
||||||
|
if !hasSubscription && hasShadowUser {
|
||||||
|
subscription = model.UserIPv6Subscription{
|
||||||
|
UserID: user.ID,
|
||||||
|
ShadowUserID: &shadowUser.ID,
|
||||||
|
IPv6Email: shadowUser.Email,
|
||||||
|
Allowed: !user.Banned,
|
||||||
|
Status: "active",
|
||||||
|
UpdatedAt: shadowUser.UpdatedAt,
|
||||||
|
}
|
||||||
|
hasSubscription = true
|
||||||
|
}
|
||||||
|
allowed := service.PluginUserAllowed(&user, user.Plan)
|
||||||
|
status := "not_allowed"
|
||||||
|
statusLabel := "Not eligible"
|
||||||
|
if allowed {
|
||||||
|
status = "eligible"
|
||||||
|
statusLabel = "Ready to enable"
|
||||||
|
}
|
||||||
|
shadowUserID := 0
|
||||||
|
shadowUpdatedAt := int64(0)
|
||||||
|
if hasSubscription {
|
||||||
|
status = firstString(subscription.Status, status)
|
||||||
|
statusLabel = "IPv6 enabled"
|
||||||
|
if subscription.Status != "active" && subscription.Status != "" {
|
||||||
|
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
|
||||||
|
}
|
||||||
|
if subscription.ShadowUserID != nil {
|
||||||
|
shadowUserID = *subscription.ShadowUserID
|
||||||
|
}
|
||||||
|
shadowUpdatedAt = subscription.UpdatedAt
|
||||||
|
} else if allowed {
|
||||||
|
statusLabel = "Ready to enable"
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"plan_name": planName(user.Plan),
|
||||||
|
"allowed": allowed || hasSubscription && subscription.Allowed,
|
||||||
|
"is_active": hasSubscription && subscription.Status == "active",
|
||||||
|
"status": status,
|
||||||
|
"status_label": statusLabel,
|
||||||
|
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
|
||||||
|
"shadow_user_id": shadowUserID,
|
||||||
|
"updated_at": shadowUpdatedAt,
|
||||||
|
"group_id": user.GroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Success(c, gin.H{
|
||||||
|
"list": list,
|
||||||
|
"pagination": gin.H{
|
||||||
|
"current": page,
|
||||||
|
"last_page": calculateLastPage(total, perPage),
|
||||||
|
"per_page": perPage,
|
||||||
|
"total": total,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminIPv6SubscriptionEnable(c *gin.Context) {
|
||||||
|
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||||
|
if userID == 0 {
|
||||||
|
Fail(c, 400, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
|
||||||
|
Fail(c, 404, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.ParentID != nil {
|
||||||
|
Fail(c, 400, "shadow users cannot be enabled again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !service.SyncIPv6ShadowAccount(&user) {
|
||||||
|
Fail(c, 403, "user plan does not support ipv6 subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminIPv6SubscriptionSyncPassword(c *gin.Context) {
|
||||||
|
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||||
|
if userID == 0 {
|
||||||
|
Fail(c, 400, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
Fail(c, 404, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !service.SyncIPv6PasswordState(&user) {
|
||||||
|
Fail(c, 404, "IPv6 user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||||
|
}
|
||||||
|
|
||||||
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
|
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
|
||||||
|
|
||||||
user, ok := currentUser(c)
|
user, ok := currentUser(c)
|
||||||
@@ -136,24 +334,41 @@ func PluginUserAddIPv6Check(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.PlanID == nil {
|
var plan *model.Plan
|
||||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
if user.PlanID != nil {
|
||||||
return
|
var loadedPlan model.Plan
|
||||||
|
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil {
|
||||||
|
plan = &loadedPlan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan model.Plan
|
var subscription model.UserIPv6Subscription
|
||||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil
|
||||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
allowed := service.PluginUserAllowed(user, plan)
|
||||||
return
|
status := "not_allowed"
|
||||||
|
statusLabel := "Not eligible"
|
||||||
|
reason := "Current plan or group is not allowed to enable IPv6"
|
||||||
|
if allowed {
|
||||||
|
status = "eligible"
|
||||||
|
statusLabel = "Ready to enable"
|
||||||
|
reason = ""
|
||||||
|
}
|
||||||
|
if hasSubscription {
|
||||||
|
status = firstString(subscription.Status, "active")
|
||||||
|
statusLabel = "IPv6 enabled"
|
||||||
|
reason = ""
|
||||||
|
if subscription.Status != "active" && subscription.Status != "" {
|
||||||
|
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
|
||||||
var count int64
|
|
||||||
database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Count(&count)
|
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"allowed": service.PluginPlanAllowed(&plan),
|
"allowed": allowed || hasSubscription && subscription.Allowed,
|
||||||
"is_active": count > 0,
|
"is_active": hasSubscription && subscription.Status == "active",
|
||||||
|
"status": status,
|
||||||
|
"status_label": statusLabel,
|
||||||
|
"reason": reason,
|
||||||
|
"ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +385,23 @@ func PluginUserAddIPv6Enable(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
payload := gin.H{
|
||||||
|
"ipv6_email": service.IPv6ShadowEmail(user.Email),
|
||||||
|
}
|
||||||
|
|
||||||
|
var shadowUser model.User
|
||||||
|
if err := database.DB.Where("parent_id = ? AND email = ?", user.ID, service.IPv6ShadowEmail(user.Email)).First(&shadowUser).Error; err == nil {
|
||||||
|
payload["shadow_user_id"] = shadowUser.ID
|
||||||
|
token, err := utils.GenerateToken(shadowUser.ID, shadowUser.IsAdmin)
|
||||||
|
if err == nil {
|
||||||
|
payload["token"] = token
|
||||||
|
payload["auth_data"] = token
|
||||||
|
service.TrackSession(shadowUser.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||||
|
_ = database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("last_login_at", time.Now().Unix()).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessMessage(c, "IPv6 subscription enabled/synced", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||||
@@ -181,13 +412,7 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
if !service.SyncIPv6PasswordState(user) {
|
||||||
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
|
|
||||||
if result.Error != nil {
|
|
||||||
Fail(c, 404, "IPv6 user not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
Fail(c, 404, "IPv6 user not found")
|
Fail(c, 404, "IPv6 user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,8 +420,6 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
|||||||
SuccessMessage(c, "Password synced to IPv6 account", true)
|
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func AdminSystemStatus(c *gin.Context) {
|
func AdminSystemStatus(c *gin.Context) {
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
"server_time": time.Now().Unix(),
|
"server_time": time.Now().Unix(),
|
||||||
@@ -210,7 +433,39 @@ func AdminSystemStatus(c *gin.Context) {
|
|||||||
// AdminSystemQueueStats returns empty queue stats (Go app has no Horizon/queue workers).
|
// AdminSystemQueueStats returns empty queue stats (Go app has no Horizon/queue workers).
|
||||||
// The React frontend polls this endpoint; returning empty data prevents 404 polling errors.
|
// The React frontend polls this endpoint; returning empty data prevents 404 polling errors.
|
||||||
func AdminSystemQueueStats(c *gin.Context) {
|
func AdminSystemQueueStats(c *gin.Context) {
|
||||||
|
fullPath := c.FullPath()
|
||||||
|
if strings.HasSuffix(fullPath, "/getHorizonFailedJobs") {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"data": []any{},
|
||||||
|
"total": 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(fullPath, "/getQueueWorkload") || strings.HasSuffix(fullPath, "/getQueueMasters") {
|
||||||
|
Success(c, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Success(c, gin.H{
|
Success(c, gin.H{
|
||||||
|
"status": true,
|
||||||
|
"wait": gin.H{"default": 0},
|
||||||
|
"recentJobs": 0,
|
||||||
|
"jobsPerMinute": 0,
|
||||||
|
"failedJobs": 0,
|
||||||
|
"processes": 0,
|
||||||
|
"pausedMasters": 0,
|
||||||
|
"periods": gin.H{
|
||||||
|
"recentJobs": 60,
|
||||||
|
"failedJobs": 168,
|
||||||
|
},
|
||||||
|
"queueWithMaxThroughput": gin.H{
|
||||||
|
"name": "default",
|
||||||
|
"throughput": 0,
|
||||||
|
},
|
||||||
|
"queueWithMaxRuntime": gin.H{
|
||||||
|
"name": "default",
|
||||||
|
"runtime": 0,
|
||||||
|
},
|
||||||
"workload": []any{},
|
"workload": []any{},
|
||||||
"masters": []any{},
|
"masters": []any{},
|
||||||
"failed_jobs": 0,
|
"failed_jobs": 0,
|
||||||
|
|||||||
@@ -250,9 +250,6 @@ func currentUser(c *gin.Context) (*model.User, bool) {
|
|||||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && !strings.Contains(user.Email, "-ipv6@") && user.PlanID != nil {
|
|
||||||
service.SyncIPv6ShadowAccount(&user)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set("user", &user)
|
c.Set("user", &user)
|
||||||
return &user, true
|
return &user, true
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"xboard-go/internal/service"
|
"xboard-go/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,6 +29,7 @@ type userThemeViewData struct {
|
|||||||
type adminAppViewData struct {
|
type adminAppViewData struct {
|
||||||
Title string
|
Title string
|
||||||
SettingsJS template.JS
|
SettingsJS template.JS
|
||||||
|
AssetNonce string
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserThemePage(c *gin.Context) {
|
func UserThemePage(c *gin.Context) {
|
||||||
@@ -42,6 +45,7 @@ func UserThemePage(c *gin.Context) {
|
|||||||
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
|
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
|
||||||
"icpNo": service.MustGetString("icp_no", ""),
|
"icpNo": service.MustGetString("icp_no", ""),
|
||||||
"psbNo": service.MustGetString("psb_no", ""),
|
"psbNo": service.MustGetString("psb_no", ""),
|
||||||
|
"staticCdnUrl": service.MustGetString("nebula_static_cdn_url", ""),
|
||||||
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
|
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ func AdminAppPage(c *gin.Context) {
|
|||||||
payload := adminAppViewData{
|
payload := adminAppViewData{
|
||||||
Title: title,
|
Title: title,
|
||||||
SettingsJS: template.JS(settingsJSON),
|
SettingsJS: template.JS(settingsJSON),
|
||||||
|
AssetNonce: strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)
|
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)
|
||||||
|
|||||||
21
internal/model/user_ipv6_subscription.go
Normal file
21
internal/model/user_ipv6_subscription.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type UserIPv6Subscription struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||||
|
UserID int `gorm:"column:user_id;uniqueIndex:idx_user_ipv6_subscription_user" json:"user_id"`
|
||||||
|
ShadowUserID *int `gorm:"column:shadow_user_id;index:idx_user_ipv6_subscription_shadow" json:"shadow_user_id"`
|
||||||
|
IPv6Email string `gorm:"column:ipv6_email;size:191" json:"ipv6_email"`
|
||||||
|
Allowed bool `gorm:"column:allowed;default:false" json:"allowed"`
|
||||||
|
Status string `gorm:"column:status;size:32;index:idx_user_ipv6_subscription_status" json:"status"`
|
||||||
|
LastSyncAt int64 `gorm:"column:last_sync_at" json:"last_sync_at"`
|
||||||
|
PasswordSyncedAt *int64 `gorm:"column:password_synced_at" json:"password_synced_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|
||||||
|
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
|
||||||
|
ShadowUser *User `gorm:"foreignKey:ShadowUserID;references:ID" json:"shadow_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserIPv6Subscription) TableName() string {
|
||||||
|
return "v2_user_ipv6_subscriptions"
|
||||||
|
}
|
||||||
19
internal/model/user_online_device.go
Normal file
19
internal/model/user_online_device.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type UserOnlineDevice struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||||
|
UserID int `gorm:"column:user_id;index:idx_user_online_devices_user;uniqueIndex:idx_user_online_devices_unique" json:"user_id"`
|
||||||
|
NodeID int `gorm:"column:node_id;index:idx_user_online_devices_node;uniqueIndex:idx_user_online_devices_unique" json:"node_id"`
|
||||||
|
IP string `gorm:"column:ip;size:191;uniqueIndex:idx_user_online_devices_unique" json:"ip"`
|
||||||
|
FirstSeenAt int64 `gorm:"column:first_seen_at" json:"first_seen_at"`
|
||||||
|
LastSeenAt int64 `gorm:"column:last_seen_at;index:idx_user_online_devices_last_seen" json:"last_seen_at"`
|
||||||
|
ExpiresAt int64 `gorm:"column:expires_at;index:idx_user_online_devices_expires" json:"expires_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|
||||||
|
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserOnlineDevice) TableName() string {
|
||||||
|
return "v2_user_online_devices"
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
|
"xboard-go/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deviceStateTTL = 10 * time.Minute
|
const deviceStateTTL = 10 * time.Minute
|
||||||
@@ -28,7 +29,10 @@ func SaveUserNodeDevices(userID, nodeID int, ips []string) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(unique)
|
sort.Strings(unique)
|
||||||
|
|
||||||
return database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL)
|
if err := database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return syncUserOnlineDevices(userID, nodeID, unique)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUsersDevices(userIDs []int) map[int][]string {
|
func GetUsersDevices(userIDs []int) map[int][]string {
|
||||||
@@ -53,6 +57,9 @@ func GetUsersDevices(userIDs []int) map[int][]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(merged)
|
sort.Strings(merged)
|
||||||
|
if len(merged) == 0 {
|
||||||
|
merged = loadUserOnlineDevicesFromDB(userID)
|
||||||
|
}
|
||||||
result[userID] = merged
|
result[userID] = merged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +73,9 @@ func SetDevices(userID, nodeID int, ips []string) error {
|
|||||||
|
|
||||||
indexKey := deviceStateUserIndexKey(userID)
|
indexKey := deviceStateUserIndexKey(userID)
|
||||||
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
|
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
|
||||||
|
if indexSnapshot == nil {
|
||||||
|
indexSnapshot = make(userDevicesSnapshot)
|
||||||
|
}
|
||||||
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
|
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
|
||||||
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
|
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
|
||||||
}
|
}
|
||||||
@@ -95,3 +105,109 @@ func deviceStateKey(userID, nodeID int) string {
|
|||||||
func deviceStateUserIndexKey(userID int) string {
|
func deviceStateUserIndexKey(userID int) string {
|
||||||
return fmt.Sprintf("device_state:user:%d:index", userID)
|
return fmt.Sprintf("device_state:user:%d:index", userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncUserOnlineDevices(userID, nodeID int, ips []string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
expiresAt := now + int64(deviceStateTTL.Seconds())
|
||||||
|
|
||||||
|
if err := database.DB.Where("user_id = ? AND node_id = ? AND expires_at <= ?", userID, nodeID, now).
|
||||||
|
Delete(&model.UserOnlineDevice{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := make([]model.UserOnlineDevice, 0)
|
||||||
|
if err := database.DB.Where("user_id = ? AND node_id = ?", userID, nodeID).Find(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingByIP := make(map[string]model.UserOnlineDevice, len(existing))
|
||||||
|
for _, item := range existing {
|
||||||
|
existingByIP[item.IP] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
seen[ip] = struct{}{}
|
||||||
|
if current, ok := existingByIP[ip]; ok {
|
||||||
|
if err := database.DB.Model(&model.UserOnlineDevice{}).
|
||||||
|
Where("id = ?", current.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"last_seen_at": now,
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
record := model.UserOnlineDevice{
|
||||||
|
UserID: userID,
|
||||||
|
NodeID: nodeID,
|
||||||
|
IP: ip,
|
||||||
|
FirstSeenAt: now,
|
||||||
|
LastSeenAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existing) > 0 {
|
||||||
|
staleIDs := make([]uint64, 0)
|
||||||
|
for _, item := range existing {
|
||||||
|
if _, ok := seen[item.IP]; !ok {
|
||||||
|
staleIDs = append(staleIDs, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(staleIDs) > 0 {
|
||||||
|
if err := database.DB.Where("id IN ?", staleIDs).Delete(&model.UserOnlineDevice{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onlineCount int64
|
||||||
|
if err := database.DB.Model(&model.UserOnlineDevice{}).
|
||||||
|
Where("user_id = ? AND expires_at > ?", userID, now).
|
||||||
|
Distinct("ip").
|
||||||
|
Count(&onlineCount).Error; err == nil {
|
||||||
|
count := int(onlineCount)
|
||||||
|
_ = database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||||
|
"online_count": count,
|
||||||
|
"last_online_at": time.Unix(now, 0),
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadUserOnlineDevicesFromDB(userID int) []string {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
var records []model.UserOnlineDevice
|
||||||
|
if err := database.DB.
|
||||||
|
Select("ip").
|
||||||
|
Where("user_id = ? AND expires_at > ?", userID, now).
|
||||||
|
Order("ip ASC").
|
||||||
|
Find(&records).Error; err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(records))
|
||||||
|
ips := make([]string, 0, len(records))
|
||||||
|
for _, item := range records {
|
||||||
|
if item.IP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item.IP]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.IP] = struct{}{}
|
||||||
|
ips = append(ips, item.IP)
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|||||||
283
internal/service/email.go
Normal file
283
internal/service/email.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Encryption string
|
||||||
|
FromAddress string
|
||||||
|
FromName string
|
||||||
|
TemplateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailMessage struct {
|
||||||
|
To []string
|
||||||
|
Subject string
|
||||||
|
TextBody string
|
||||||
|
HTMLBody string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadEmailConfig() EmailConfig {
|
||||||
|
fromAddress := strings.TrimSpace(MustGetString("email_from_address", ""))
|
||||||
|
if fromAddress == "" {
|
||||||
|
fromAddress = strings.TrimSpace(MustGetString("email_from", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fromName := strings.TrimSpace(MustGetString("email_from_name", ""))
|
||||||
|
if fromName == "" {
|
||||||
|
fromName = strings.TrimSpace(MustGetString("app_name", "XBoard"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmailConfig{
|
||||||
|
Host: strings.TrimSpace(MustGetString("email_host", "")),
|
||||||
|
Port: MustGetInt("email_port", 465),
|
||||||
|
Username: strings.TrimSpace(MustGetString("email_username", "")),
|
||||||
|
Password: MustGetString("email_password", ""),
|
||||||
|
Encryption: normalizeEmailEncryption(MustGetString("email_encryption", "")),
|
||||||
|
FromAddress: fromAddress,
|
||||||
|
FromName: fromName,
|
||||||
|
TemplateName: firstNonEmpty(strings.TrimSpace(MustGetString("email_template", "")), "classic"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg EmailConfig) DebugConfig() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"driver": "smtp",
|
||||||
|
"host": cfg.Host,
|
||||||
|
"port": cfg.Port,
|
||||||
|
"encryption": cfg.Encryption,
|
||||||
|
"username": cfg.Username,
|
||||||
|
"from_address": cfg.SenderAddress(),
|
||||||
|
"from_name": cfg.FromName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg EmailConfig) SenderAddress() string {
|
||||||
|
return firstNonEmpty(strings.TrimSpace(cfg.FromAddress), strings.TrimSpace(cfg.Username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg EmailConfig) Validate() error {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(cfg.Host) == "":
|
||||||
|
return errors.New("email_host is required")
|
||||||
|
case cfg.Port <= 0:
|
||||||
|
return errors.New("email_port is required")
|
||||||
|
case cfg.SenderAddress() == "":
|
||||||
|
return errors.New("email_from_address or email_username is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendMailWithCurrentSettings(message EmailMessage) error {
|
||||||
|
return SendMail(LoadEmailConfig(), message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendMail(cfg EmailConfig, message EmailMessage) error {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(message.To) == 0 {
|
||||||
|
return errors.New("at least one recipient is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.Subject) == "" {
|
||||||
|
return errors.New("subject is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.TextBody) == "" && strings.TrimSpace(message.HTMLBody) == "" {
|
||||||
|
return errors.New("message body is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := buildEmailMessage(cfg, message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := dialSMTP(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if cfg.Username != "" && cfg.Password != "" {
|
||||||
|
if ok, _ := client.Extension("AUTH"); !ok {
|
||||||
|
return errors.New("smtp server does not support authentication")
|
||||||
|
}
|
||||||
|
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Mail(cfg.SenderAddress()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, recipient := range message.To {
|
||||||
|
recipient = strings.TrimSpace(recipient)
|
||||||
|
if recipient == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(recipient); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := writer.Write(payload); err != nil {
|
||||||
|
_ = writer.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialSMTP(cfg EmailConfig) (*smtp.Client, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
|
||||||
|
switch cfg.Encryption {
|
||||||
|
case "ssl":
|
||||||
|
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return smtp.NewClient(conn, cfg.Host)
|
||||||
|
case "tls":
|
||||||
|
client, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||||
|
_ = client.Close()
|
||||||
|
return nil, errors.New("smtp server does not support STARTTLS")
|
||||||
|
}
|
||||||
|
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
default:
|
||||||
|
return smtp.Dial(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEmailMessage(cfg EmailConfig, message EmailMessage) ([]byte, error) {
|
||||||
|
fromAddress := cfg.SenderAddress()
|
||||||
|
if _, err := mail.ParseAddress(fromAddress); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid sender address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toAddresses := make([]string, 0, len(message.To))
|
||||||
|
for _, recipient := range message.To {
|
||||||
|
recipient = strings.TrimSpace(recipient)
|
||||||
|
if recipient == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(recipient); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid recipient address: %w", err)
|
||||||
|
}
|
||||||
|
toAddresses = append(toAddresses, recipient)
|
||||||
|
}
|
||||||
|
if len(toAddresses) == 0 {
|
||||||
|
return nil, errors.New("no valid recipients")
|
||||||
|
}
|
||||||
|
|
||||||
|
fromHeader := (&mail.Address{Name: cfg.FromName, Address: fromAddress}).String()
|
||||||
|
subjectHeader := mime.QEncoding.Encode("UTF-8", strings.TrimSpace(message.Subject))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeHeader := func(key, value string) {
|
||||||
|
buf.WriteString(key)
|
||||||
|
buf.WriteString(": ")
|
||||||
|
buf.WriteString(value)
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeHeader("From", fromHeader)
|
||||||
|
writeHeader("To", strings.Join(toAddresses, ", "))
|
||||||
|
writeHeader("Subject", subjectHeader)
|
||||||
|
writeHeader("MIME-Version", "1.0")
|
||||||
|
|
||||||
|
textBody := message.TextBody
|
||||||
|
htmlBody := message.HTMLBody
|
||||||
|
|
||||||
|
if strings.TrimSpace(htmlBody) != "" {
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, mw.Boundary()))
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
|
if strings.TrimSpace(textBody) == "" {
|
||||||
|
textBody = htmlBody
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeMIMEPart(mw, "text/plain", textBody); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeMIMEPart(mw, "text/html", htmlBody); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
writeHeader("Content-Type", `text/plain; charset="UTF-8"`)
|
||||||
|
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
buf.WriteString("\r\n")
|
||||||
|
|
||||||
|
qp := quotedprintable.NewWriter(&buf)
|
||||||
|
if _, err := qp.Write([]byte(textBody)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := qp.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMIMEPart(mw *multipart.Writer, contentType string, body string) error {
|
||||||
|
header := textproto.MIMEHeader{}
|
||||||
|
header.Set("Content-Type", fmt.Sprintf(`%s; charset="UTF-8"`, contentType))
|
||||||
|
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
|
||||||
|
part, err := mw.CreatePart(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
qp := quotedprintable.NewWriter(part)
|
||||||
|
if _, err := qp.Write([]byte(body)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return qp.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmailEncryption(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "ssl":
|
||||||
|
return "ssl"
|
||||||
|
case "tls", "starttls":
|
||||||
|
return "tls"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,21 +76,26 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SyncIPv6ShadowAccount(user *model.User) bool {
|
func SyncIPv6ShadowAccount(user *model.User) bool {
|
||||||
if user == nil || user.PlanID == nil {
|
if user == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan model.Plan
|
var plan *model.Plan
|
||||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
if user.PlanID != nil {
|
||||||
return false
|
var loadedPlan model.Plan
|
||||||
|
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil {
|
||||||
|
plan = &loadedPlan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !PluginPlanAllowed(&plan) {
|
if !PluginUserAllowed(user, plan) {
|
||||||
|
syncIPv6SubscriptionRecord(user, nil, false, "not_allowed")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6Email := IPv6ShadowEmail(user.Email)
|
ipv6Email := IPv6ShadowEmail(user.Email)
|
||||||
var ipv6User model.User
|
var ipv6User model.User
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
created := false
|
||||||
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
|
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
|
||||||
ipv6User = *user
|
ipv6User = *user
|
||||||
ipv6User.ID = 0
|
ipv6User.ID = 0
|
||||||
@@ -102,6 +107,7 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
|
|||||||
ipv6User.T = 0
|
ipv6User.T = 0
|
||||||
ipv6User.ParentID = &user.ID
|
ipv6User.ParentID = &user.ID
|
||||||
ipv6User.CreatedAt = now
|
ipv6User.CreatedAt = now
|
||||||
|
created = true
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv6User.Email = ipv6Email
|
ipv6User.Email = ipv6Email
|
||||||
@@ -118,20 +124,41 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
|
|||||||
ipv6User.GroupID = &groupID
|
ipv6User.GroupID = &groupID
|
||||||
}
|
}
|
||||||
|
|
||||||
return database.DB.Save(&ipv6User).Error == nil
|
if err := database.DB.Save(&ipv6User).Error; err != nil {
|
||||||
}
|
syncIPv6SubscriptionRecord(user, nil, true, "eligible")
|
||||||
|
|
||||||
func PluginPlanAllowed(plan *model.Plan) bool {
|
|
||||||
if plan == nil {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, raw := range strings.Split(GetPluginConfigString(PluginUserAddIPv6, "allowed_plans", ""), ",") {
|
if created {
|
||||||
if parsePluginPositiveInt(strings.TrimSpace(raw), 0) == plan.ID {
|
_ = database.DB.Model(&model.User{}).Where("id = ?", ipv6User.ID).Update("parent_id", user.ID).Error
|
||||||
|
}
|
||||||
|
syncIPv6SubscriptionRecord(user, &ipv6User, true, "active")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func PluginPlanAllowed(plan *model.Plan) bool {
|
||||||
|
return PluginUserAllowed(nil, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PluginUserAllowed(user *model.User, plan *model.Plan) bool {
|
||||||
|
allowedPlans := GetPluginConfigIntList(PluginUserAddIPv6, "allowed_plans")
|
||||||
|
for _, planID := range allowedPlans {
|
||||||
|
if plan != nil && plan.ID == planID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowedGroups := GetPluginConfigIntList(PluginUserAddIPv6, "allowed_groups")
|
||||||
|
for _, groupID := range allowedGroups {
|
||||||
|
if user != nil && user.GroupID != nil && *user.GroupID == groupID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
referenceFlag := strings.ToLower(GetPluginConfigString(PluginUserAddIPv6, "reference_flag", "ipv6"))
|
referenceFlag := strings.ToLower(GetPluginConfigString(PluginUserAddIPv6, "reference_flag", "ipv6"))
|
||||||
reference := ""
|
reference := ""
|
||||||
if plan.Reference != nil {
|
if plan.Reference != nil {
|
||||||
@@ -140,6 +167,68 @@ func PluginPlanAllowed(plan *model.Plan) bool {
|
|||||||
return referenceFlag != "" && strings.Contains(reference, referenceFlag)
|
return referenceFlag != "" && strings.Contains(reference, referenceFlag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPluginConfigIntList(code, key string) []int {
|
||||||
|
cfg := GetPluginConfig(code)
|
||||||
|
value, ok := cfg[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return []int{}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseItem := func(raw any) int {
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case int:
|
||||||
|
if typed > 0 {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if typed > 0 {
|
||||||
|
return int(typed)
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if typed > 0 {
|
||||||
|
return int(typed)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
return parsePluginPositiveInt(typed, 0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]int, 0)
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
appendValue := func(candidate int) {
|
||||||
|
if candidate <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[candidate]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[candidate] = struct{}{}
|
||||||
|
result = append(result, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendValue(parseItem(item))
|
||||||
|
}
|
||||||
|
case []int:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendValue(item)
|
||||||
|
}
|
||||||
|
case []float64:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendValue(int(item))
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
for _, raw := range strings.Split(typed, ",") {
|
||||||
|
appendValue(parsePluginPositiveInt(strings.TrimSpace(raw), 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func IPv6ShadowEmail(email string) string {
|
func IPv6ShadowEmail(email string) string {
|
||||||
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
|
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
|
||||||
parts := strings.SplitN(email, "@", 2)
|
parts := strings.SplitN(email, "@", 2)
|
||||||
@@ -156,3 +245,67 @@ func parsePluginPositiveInt(raw string, defaultValue int) int {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SyncIPv6PasswordState(user *model.User) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6Email := IPv6ShadowEmail(user.Email)
|
||||||
|
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
updates := map[string]any{
|
||||||
|
"password_synced_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription model.UserIPv6Subscription
|
||||||
|
if err := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error; err == nil {
|
||||||
|
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("id = ?", subscription.ID).Updates(updates).Error
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncIPv6SubscriptionRecord(user *model.User, shadowUser *model.User, allowed bool, status string) {
|
||||||
|
if user == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
record := model.UserIPv6Subscription{
|
||||||
|
UserID: user.ID,
|
||||||
|
IPv6Email: IPv6ShadowEmail(user.Email),
|
||||||
|
Allowed: allowed,
|
||||||
|
Status: status,
|
||||||
|
LastSyncAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if shadowUser != nil {
|
||||||
|
record.ShadowUserID = &shadowUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing model.UserIPv6Subscription
|
||||||
|
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
|
||||||
|
updates := map[string]any{
|
||||||
|
"ipv6_email": record.IPv6Email,
|
||||||
|
"allowed": allowed,
|
||||||
|
"status": status,
|
||||||
|
"last_sync_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
if shadowUser != nil {
|
||||||
|
updates["shadow_user_id"] = shadowUser.ID
|
||||||
|
} else {
|
||||||
|
updates["shadow_user_id"] = nil
|
||||||
|
}
|
||||||
|
_ = database.DB.Model(&model.UserIPv6Subscription{}).Where("id = ?", existing.ID).Updates(updates).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = database.DB.Create(&record).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
"xboard-go/internal/database"
|
"xboard-go/internal/database"
|
||||||
|
"xboard-go/internal/model"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,7 @@ func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
|
|||||||
list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent)
|
list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent)
|
||||||
list[i].LastUsedAt = now
|
list[i].LastUsedAt = now
|
||||||
saveSessions(userID, list)
|
saveSessions(userID, list)
|
||||||
|
syncUserSessionOnlineState(userID, list[i].IP, now)
|
||||||
return list[i]
|
return list[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
|
|||||||
}
|
}
|
||||||
list = append(list, record)
|
list = append(list, record)
|
||||||
saveSessions(userID, list)
|
saveSessions(userID, list)
|
||||||
|
syncUserSessionOnlineState(userID, record.IP, now)
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +180,45 @@ func saveSessions(userID int, sessions []SessionRecord) {
|
|||||||
_ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL)
|
_ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncUserSessionOnlineState(userID int, ip string, now int64) {
|
||||||
|
updates := map[string]any{
|
||||||
|
"last_online_at": time.Unix(now, 0),
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessions := activeSessions(userID, now); len(sessions) > 0 {
|
||||||
|
updates["online_count"] = len(sessions)
|
||||||
|
}
|
||||||
|
_ = database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
|
||||||
|
|
||||||
|
ip = firstNonEmpty(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record := model.UserOnlineDevice{
|
||||||
|
UserID: userID,
|
||||||
|
NodeID: 0,
|
||||||
|
IP: ip,
|
||||||
|
FirstSeenAt: now,
|
||||||
|
LastSeenAt: now,
|
||||||
|
ExpiresAt: now + int64(sessionTTL.Seconds()),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing model.UserOnlineDevice
|
||||||
|
if err := database.DB.Where("user_id = ? AND node_id = ? AND ip = ?", userID, 0, ip).First(&existing).Error; err == nil {
|
||||||
|
_ = database.DB.Model(&model.UserOnlineDevice{}).Where("id = ?", existing.ID).Updates(map[string]any{
|
||||||
|
"last_seen_at": now,
|
||||||
|
"expires_at": record.ExpiresAt,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = database.DB.Create(&record).Error
|
||||||
|
}
|
||||||
|
|
||||||
func hashToken(token string) string {
|
func hashToken(token string) string {
|
||||||
sum := sha256.Sum256([]byte(token))
|
sum := sha256.Sum256([]byte(token))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user