基本功能已初步完善
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled

This commit is contained in:
CN-JS-HuiBai
2026-04-17 20:41:47 +08:00
parent 25fd919477
commit b3435e5ef8
34 changed files with 3495 additions and 429 deletions

View File

@@ -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:

BIN
api.exe

Binary file not shown.

View File

@@ -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)

View File

@@ -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"})
})
} }

View File

@@ -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;

View File

@@ -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">&rarr;</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">&rarr;</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
View 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);

View File

@@ -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({}),

View File

@@ -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" },

View File

@@ -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 },

View File

@@ -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,
}); });

View 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;

View File

@@ -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>

View 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="首页">&laquo;</button>
<button id="prev-btn" class="btn icon-btn" aria-label="上一页">&lsaquo;</button>
<button id="next-btn" class="btn icon-btn" aria-label="下一页">&rsaquo;</button>
<button id="last-btn" class="btn icon-btn" aria-label="末页">&raquo;</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>&rarr;</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>

View File

@@ -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;

View File

@@ -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")
} }

View File

@@ -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 ""
}
}

View File

@@ -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")
@@ -46,16 +50,16 @@ func AdminPaymentSave(c *gin.Context) {
return return
} }
id := intFromAny(payload["id"]) id := intFromAny(payload["id"])
// Complex configuration usually stored as JSON // Complex configuration usually stored as JSON
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)
@@ -106,15 +114,15 @@ func AdminNoticeSave(c *gin.Context) {
c.ShouldBindJSON(&payload) c.ShouldBindJSON(&payload)
id := intFromAny(payload["id"]) id := intFromAny(payload["id"])
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,
} }
if id > 0 { if id > 0 {
database.DB.Model(&model.Notice{}).Where("id = ?", id).Updates(values) database.DB.Model(&model.Notice{}).Where("id = ?", id).Updates(values)
} else { } else {
@@ -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, ">")
}

View File

@@ -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())))

View 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(),
})
}

View File

@@ -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 {

View File

@@ -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
} }
} }

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View 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"
}

View 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"
}

View File

@@ -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
View 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 ""
}
}

View File

@@ -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
}

View File

@@ -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[:])

View File

@@ -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
} }