基本功能已初步完善
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:
build:
runs-on: ubuntu-latest
runs-on: docker.gitea.com/runner-images:ubuntu-22.04
strategy:
fail-fast: false
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/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)
// Knowledge Base
@@ -149,14 +145,6 @@ func main() {
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
trafficResetGrp := admin.Group("/traffic-reset")
{
@@ -190,14 +178,6 @@ func main() {
admin.POST("/server/route/save", handler.AdminServerRouteSave)
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
admin.GET("/notice/fetch", handler.AdminNoticeFetch)
admin.POST("/notice/save", handler.AdminNoticeSave)

View File

@@ -4,6 +4,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"xboard-go/internal/config"
"xboard-go/internal/database"
"xboard-go/internal/handler"
@@ -188,12 +189,16 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin.GET("/config/fetch", handler.AdminConfigFetch)
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("/stat/getStats", handler.AdminDashboardSummary)
admin.GET("/stat/getOverride", handler.AdminDashboardSummary)
admin.GET("/stat/getTrafficRank", handler.AdminGetTrafficRank)
admin.GET("/stat/getOrder", handler.AdminGetOrderStats)
admin.POST("/stat/getStatUser", handler.AdminGetStatUser)
admin.GET("/system/getSystemStatus", handler.AdminSystemStatus)
admin.GET("/system/getQueueStats", handler.AdminSystemQueueStats)
@@ -222,25 +227,40 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin.POST("/plan/sort", handler.AdminPlanSort)
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/cancel", handler.AdminOrderCancel)
admin.GET("/coupon/fetch", handler.AdminCouponsFetch)
admin.POST("/coupon/save", handler.AdminCouponSave)
admin.POST("/coupon/drop", handler.AdminCouponDrop)
admin.POST("/order/update", handler.AdminOrderUpdate)
admin.POST("/order/assign", handler.AdminOrderAssign)
admin.GET("/user/fetch", handler.AdminUsersFetch)
admin.POST("/user/fetch", handler.AdminUsersFetch)
admin.POST("/user/update", handler.AdminUserUpdate)
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/drop", handler.AdminUserDelete)
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/logs", handler.AdminTrafficResetFetch)
admin.POST("/traffic-reset/reset-user", handler.AdminTrafficResetUser)
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
admin.GET("/realname/records", handler.PluginRealNameRecords)
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/approve-all", handler.PluginRealNameApproveAll)
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) {
@@ -267,5 +290,14 @@ func registerWebRoutes(router *gin.Engine) {
router.GET("/dashboard", handler.UserThemePage)
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.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);
}
.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 */
.btn {
display: inline-flex;

View File

@@ -11,7 +11,7 @@
const state = {
token: readToken(),
user: null,
route: normalizeRoute(readRoute()),
route: "overview",
busy: false,
message: "",
messageType: "info",
@@ -38,6 +38,7 @@
tickets: { list: [], pagination: null },
realname: { list: [], pagination: null },
devices: { list: [], pagination: null },
ipv6: { list: [], pagination: null },
expandedNodes: new Set()
};
@@ -54,9 +55,12 @@
"ticket-manage": { title: "工单中心", description: "查看用户工单和处理状态。" },
realname: { title: "实名认证", description: "审核实名记录和同步状态。" },
"user-online-devices": { title: "在线设备", description: "查看用户在线 IP 和设备分布。" },
"user-ipv6-subscription": { title: "IPv6 子账号", description: "管理 IPv6 阴影账号与密码同步。" },
"system-config": { title: "系统设置", description: "编辑站点、订阅和安全参数。" }
};
state.route = normalizeRoute(readRoute());
boot();
async function boot() {
@@ -146,7 +150,7 @@
state.plans = toArray(unwrap(plans));
state.groups = toArray(unwrap(groups));
} 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);
} else if (state.route === "coupon-manage") {
state.coupons = toArray(unwrap(await request(cfg.api.coupons)));
@@ -156,18 +160,21 @@
request(cfg.api.plans),
request(cfg.api.serverGroups)
]);
state.users = normalizeListPayload(unwrap(users));
state.users = normalizeListPayload(users);
state.plans = toArray(unwrap(plans));
state.groups = toArray(unwrap(groups));
} 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);
} 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");
} 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);
} 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") {
state.config = unwrap(await request(cfg.api.adminConfig));
}
@@ -426,6 +433,21 @@
if (action === "sync-all") {
await adminPost(`${cfg.api.realnameBase}/sync-all`, {});
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") {
try {
setBusy(true);
const payload = unwrap(await request("/api/v1/passport/auth/login", {
const payload = unwrap(await request("/api/v2/passport/auth/login", {
method: "POST",
auth: false,
body: serializeForm(form)
@@ -571,6 +593,7 @@
navItem("user-manage", "用户管理", "账号、订阅、流量"),
navItem("realname", "实名认证", "实名审核"),
navItem("user-online-devices", "在线设备", "在线 IP 与会话"),
navItem("user-ipv6-subscription", "IPv6 子账号", "开通与密码同步"),
navItem("ticket-manage", "工单中心", "用户支持")
]),
renderSidebarGroup("system", "系统", [
@@ -643,6 +666,7 @@
if (state.route === "ticket-manage") return renderTicketManage();
if (state.route === "realname") return renderRealnameManage();
if (state.route === "user-online-devices") return renderOnlineDevices();
if (state.route === "user-ipv6-subscription") return renderIPv6Manage();
if (state.route === "system-config") return renderSystemConfig();
return renderOverview();
}
@@ -719,10 +743,13 @@
const toggle = hasChildren
? `<button class="node-toggle" data-action="node-expand" data-id="${node.id}">${expanded ? "" : "+"}</button>`
: '<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 {
className: isChild ? "node-child" : "",
cells: [
`<code>${node.id}</code>`,
relationCell,
[
'<div class="node-name-block">',
toggle,
@@ -843,10 +870,13 @@
function renderUserManage() {
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>`,
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(""),
renderStatus(user.banned ? "banned" : "active"),
escapeHtml(`${formatTraffic((user.u || 0) + (user.d || 0))} / ${formatTraffic(user.transfer_enable)}`),
@@ -922,6 +952,28 @@
].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() {
const sections = ["site", "subscribe", "server", "safe", "invite", "frontend"];
const activeTab = sections.includes(state.configTab) ? state.configTab : "site";
@@ -1163,6 +1215,10 @@
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) {
return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(String(value))}" />`;
}
@@ -1432,9 +1488,9 @@
const text = String(status || "-");
const normalized = text.toLowerCase();
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";
} else if (/(pending|warn|unverified)/.test(normalized)) {
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
type = "warn";
}
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: "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",
lazy: async () => ({
@@ -195499,36 +195484,6 @@ const DQe =
).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,
}),
},
{
path: "realname",
lazy: async () => ({ Component: codexNativeRealnamePage }),
},
{
path: "online-devices",
lazy: async () => ({ Component: codexNativeOnlineDevicesPage }),
},
{
path: "ipv6-subscription",
lazy: async () => ({ Component: codexNativeIPv6Page }),
},
{
path: "traffic-reset-logs",
lazy: async () => ({
@@ -233443,17 +233410,11 @@ var Zst = {
href: "/config/system",
icon: Q.jsx(Kf, { size: 18 }),
},
{
title: "nav:pluginManagement",
label: "",
href: "/config/plugin",
icon: Q.jsx(Fat, { size: 18 }),
},
{
title: "nav:themeConfig",
label: "",
href: "/config/theme",
icon: Q.jsx(im, { size: 18 }),
icon: Q.jsx(Kf, { size: 18 }),
},
{
title: "nav:noticeManagement",
@@ -233461,12 +233422,6 @@ var Zst = {
href: "/config/notice",
icon: Q.jsx(pm, { size: 18 }),
},
{
title: "nav:paymentConfig",
label: "",
href: "/config/payment",
icon: Q.jsx(tm, { size: 18 }),
},
{
title: "nav:knowledgeManagement",
label: "",
@@ -233519,18 +233474,6 @@ var Zst = {
href: "/finance/order",
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",
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", {
className: Rf("grid gap-4 md:grid-cols-2 lg:grid-cols-4", e),
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, {
title: n("dashboard:stats.pendingTickets"),
value: i.ticketPendingTotal,
@@ -264072,38 +264013,6 @@ function x$t({ className: e }) {
onClick: () => t("/user/ticket"),
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, {
title: n("dashboard:stats.totalUsers"),
value: i.totalUsers,
@@ -269438,7 +269347,7 @@ const tGt = Object.freeze(
className: "space-y-6",
children: Q.jsxs("div", {
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", {
className: "flex flex-col items-start gap-0.5 leading-tight",
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 &&
Q.jsxs("span", {
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() {
const [e] = Hg(),
[t, n] = H.useState({}),

View File

@@ -3,10 +3,7 @@ export const navigationGroups = [
key: "config",
items: [
{ 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/payment", titleKey: "nav:paymentConfig" },
{ href: "/config/knowledge", titleKey: "nav:knowledgeManagement" },
],
},
@@ -16,10 +13,7 @@ export const navigationGroups = [
},
{
key: "finance",
items: [
{ href: "/finance/plan", titleKey: "nav:planManagement" },
{ href: "/finance/gift-card", titleKey: "nav:giftCardManagement" },
],
items: [{ href: "/finance/plan", titleKey: "nav:planManagement" }],
},
{
key: "user",
@@ -37,6 +31,7 @@ export const navigationGroups = [
{ href: "/config/system/invite", titleKey: "nav:inviteConfig" },
{ href: "/config/system/server", titleKey: "nav:serverConfig" },
{ href: "/config/system/email", titleKey: "nav:emailConfig" },
{ href: "/config/theme", titleKey: "nav:themeConfig" },
{ href: "/config/system/telegram", titleKey: "nav:telegramConfig" },
{ href: "/config/system/app", titleKey: "nav:appConfig" },
{ 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 SystemAppPage from "../pages/config/system/SystemAppPage.js";
import SubscribeTemplatePage from "../pages/config/system/SubscribeTemplatePage.js";
import PaymentConfigPage from "../pages/config/PaymentConfigPage.js";
import PluginManagementPage from "../pages/config/PluginManagementPage.js";
import ThemeConfigPage from "../pages/config/ThemeConfigPage.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 FinancePlanPage from "../pages/finance/FinancePlanPage.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 UserTicketPage from "../pages/user/UserTicketPage.js";
import TrafficResetLogsPage from "../pages/user/TrafficResetLogsPage.js";
@@ -42,7 +39,6 @@ export const reverseRoutes = [
{ path: "/config/system/telegram", page: SystemTelegramPage },
{ path: "/config/system/app", page: SystemAppPage },
{ path: "/config/system/subscribe-template", page: SubscribeTemplatePage },
{ path: "/config/payment", page: PaymentConfigPage },
{ path: "/config/plugin", page: PluginManagementPage },
{ path: "/config/theme", page: ThemeConfigPage },
{ path: "/config/notice", page: NoticeManagementPage },
@@ -52,8 +48,6 @@ export const reverseRoutes = [
{ path: "/server/route", page: ServerRoutePage },
{ path: "/finance/plan", page: FinancePlanPage },
{ path: "/finance/order", page: FinanceOrderPage },
{ path: "/finance/coupon", page: FinanceCouponPage },
{ path: "/finance/gift-card", page: FinanceGiftCardPage },
{ path: "/user/manage", page: UserManagePage },
{ path: "/user/ticket", page: UserTicketPage },
{ 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",
routePath: "/config/theme",
moduleId: "WQt",
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>
window.settings = {{.SettingsJS}};
</script>
<script src="/admin-assets/locales/zh-CN.js"></script>
<script src="/admin-assets/locales/en-US.js"></script>
<script src="/admin-assets/locales/ru-RU.js"></script>
<script src="/admin-assets/locales/ko-KR.js"></script>
<link rel="stylesheet" crossorigin href="/admin-assets/assets/index-DTPKq_WI.css">
<script src="/admin-assets/locales/zh-CN.js?v={{.AssetNonce}}"></script>
<script src="/admin-assets/locales/en-US.js?v={{.AssetNonce}}"></script>
<script src="/admin-assets/locales/ru-RU.js?v={{.AssetNonce}}"></script>
<script src="/admin-assets/locales/ko-KR.js?v={{.AssetNonce}}"></script>
<link rel="stylesheet" crossorigin href="/admin-assets/assets/index-DTPKq_WI.css?v={{.AssetNonce}}">
</head>
<body>
<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>
</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(),
ipv6User: null,
ipv6Subscribe: null,
ipv6Eligibility: null,
ipv6Servers: [],
sessionOverview: null,
ipv6SessionOverview: null,
@@ -105,22 +106,23 @@
fetchJson("/api/v1/user/getSubscribe", { method: "GET" }),
fetchJson("/api/v1/user/getStat", { 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" }),
fetchSessionOverview(),
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.subscribe = unwrap(results[1]);
state.stats = unwrap(results[2]);
state.servers = unwrap(results[3]) || [];
state.knowledge = unwrap(results[4]) || [];
state.tickets = unwrap(results[5]) || [];
state.sessionOverview = results[6];
state.appConfig = unwrap(results[7]) || {};
state.realNameVerification = results[8] || null;
state.knowledge = [];
state.tickets = unwrap(results[4]) || [];
state.sessionOverview = results[5];
state.appConfig = unwrap(results[6]) || {};
state.realNameVerification = results[7] || null;
state.ipv6Eligibility = unwrap(results[8]) || null;
if (state.ipv6AuthToken) {
try {
@@ -917,7 +919,12 @@
async function handleEnableIpv6(actionEl) {
actionEl.disabled = true;
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");
// 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),
@@ -1394,13 +1401,6 @@
'</section>'
].join("");
}
if (state.currentRoute === "notices") {
return [
'<section class="dashboard-grid">',
renderKnowledgeSection(state.knowledge || []),
'</section>'
].join("");
}
if (state.currentRoute === "tickets") {
return [
'<section class="dashboard-grid">',
@@ -1425,7 +1425,7 @@
].join("");
}
return [
var html = [
'<section class="dashboard-grid dashboard-grid--overview">',
renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, "span-12 section-card--overview"),
renderIpv6TrafficOverviewCard("span-12"),
@@ -1433,6 +1433,7 @@
renderRealNameVerificationOverviewCard("span-12 section-card--overview"),
'</section>'
].join("");
return html;
}
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
@@ -1527,6 +1528,7 @@
sidebarLink("security", "账号安全", "修改密码与安全设置"),
'</div>'
].join("");
content = content.replace(/<button class="sidebar-link[^"]*" data-action="navigate" data-route="notices"[\s\S]*?<\/button>/, "");
content = content.replace(
'</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";
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
var user = isIpv6 ? state.ipv6User : state.user;
var ipv6Eligibility = state.ipv6Eligibility || {};
var prefix = isIpv6 ? "IPv6 " : "";
var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe";
var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security";
@@ -1592,7 +1595,7 @@
return "";
}
return [
var html = [
'<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">',
'<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>' : '',
"</article>"
].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) {
@@ -2198,7 +2213,7 @@
var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-";
var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-";
return [
var html = [
'<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="kpi-row">',
@@ -2387,7 +2402,6 @@
"access-history",
"nodes",
"ipv6-nodes",
"notices",
"tickets",
"real-name",
"security"
@@ -2479,6 +2493,7 @@
clearIpv6Token();
state.ipv6User = null;
state.ipv6Subscribe = null;
state.ipv6Eligibility = null;
state.ipv6Servers = [];
state.ipv6SessionOverview = null;
state.ipv6CachedSubNodes = null;
@@ -2505,6 +2520,7 @@
state.ipv6AuthToken = "";
state.ipv6User = null;
state.ipv6Subscribe = null;
state.ipv6Eligibility = null;
state.ipv6Servers = [];
state.sessionOverview = null;
state.ipv6SessionOverview = null;

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"xboard-go/internal/config"
"xboard-go/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
@@ -24,11 +25,20 @@ func InitDB() {
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
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")
}

View File

@@ -5,7 +5,9 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
@@ -47,21 +49,62 @@ func AdminConfigSave(c *gin.Context) {
Success(c, true)
}
// AdminGetEmailTemplate list available email templates.
func AdminGetEmailTemplate(c *gin.Context) {
path := filepath.Join("resource", "views", "mail")
files, err := listFiles(path, "*")
if err != nil {
Success(c, []string{})
func AdminTestSendMail(c *gin.Context) {
config := service.LoadEmailConfig()
recipient := currentAdminEmail(c)
if recipient == "" {
recipient = config.SenderAddress()
}
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
}
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.
func AdminGetThemeTemplate(c *gin.Context) {
path := filepath.Join("public", "theme")
files, err := listFiles(path, "*")
files, err := listDirectoryEntries(path)
if err != nil {
Success(c, []string{})
return
@@ -111,7 +154,7 @@ func getAllConfigMappings() gin.H {
"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),
"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"),
},
"frontend": gin.H{
@@ -142,6 +185,30 @@ func getAllConfigMappings() gin.H {
"register_limit_count": service.MustGetInt("register_limit_count", 3),
"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:
val = strconv.FormatInt(v, 10)
case float64:
val = fmt.Sprintf("%f", v)
val = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
val = "1"
@@ -166,19 +233,109 @@ func saveSetting(name string, value any) {
// 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)
if err != nil {
return nil, err
}
var files []string
for _, entry := range entries {
if !entry.IsDir() {
files = append(files, entry.Name())
}
}
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 (
"net/http"
"strconv"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// --- Stat Extra ---
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)
if userId == 0 {
Fail(c, http.StatusBadRequest, "user_id is required")
@@ -68,7 +72,9 @@ func AdminPaymentSave(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)
database.DB.Delete(&model.Payment{}, payload.ID)
Success(c, true)
@@ -85,7 +91,9 @@ func AdminPaymentShow(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)
for i, id := range payload.IDs {
database.DB.Model(&model.Payment{}).Where("id = ?", id).Update("sort", i)
@@ -125,7 +133,9 @@ func AdminNoticeSave(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)
database.DB.Delete(&model.Notice{}, payload.ID)
Success(c, true)
@@ -148,10 +158,22 @@ func AdminNoticeSort(c *gin.Context) {
// --- Order Extra ---
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)
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))
}
@@ -177,11 +199,22 @@ func AdminOrderUpdate(c *gin.Context) {
// --- User Extra ---
func AdminUserResetSecret(c *gin.Context) {
var payload struct{ ID int `json:"id"` }
c.ShouldBindJSON(&payload)
newUuid := "new-uuid-placeholder" // Generate actual UUID
database.DB.Model(&model.User{}).Where("id = ?", payload.ID).Update("uuid", newUuid)
Success(c, true)
var payload struct {
ID int `json:"id"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.ID <= 0 {
Fail(c, http.StatusBadRequest, "user id is required")
return
}
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) {
@@ -190,8 +223,49 @@ func AdminUserSendMail(c *gin.Context) {
Subject string `json:"subject"`
Content string `json:"content"`
}
c.ShouldBindJSON(&payload)
// Logic to send email
if err := c.ShouldBindJSON(&payload); err != nil {
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)
}
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.
func AdminGiftCardFetch(c *gin.Context) {
id := c.Query("id")
params := getFetchParams(c)
id := params["id"]
if id != "" {
var template model.GiftCardTemplate
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})
}
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 {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
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 pendingOrders int64
var pendingTickets int64
var commissionPendingTotal int64
var onlineUsers int64
var onlineNodes int64
var onlineDevices int64
@@ -30,6 +31,10 @@ func AdminDashboardSummary(c *gin.Context) {
database.DB.Model(&model.User{}).Count(&totalUsers)
database.DB.Model(&model.Order{}).Count(&totalOrders)
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.Server{}).Where("show = ?", true).Count(&onlineNodes) // Simplified online check
@@ -81,10 +86,13 @@ func AdminDashboardSummary(c *gin.Context) {
"currentMonthIncome": currentMonthIncome,
"lastMonthIncome": lastMonthIncome,
"monthIncomeGrowth": monthIncomeGrowth,
"totalOrders": totalOrders,
"pendingOrders": pendingOrders,
"currentMonthNewUsers": currentMonthNewUsers,
"totalUsers": totalUsers,
"activeUsers": totalUsers, // Placeholder for valid subscription count
"userGrowth": userGrowth,
"commissionPendingTotal": commissionPendingTotal,
"onlineUsers": onlineUsers,
"onlineDevices": onlineDevices,
"ticketPendingTotal": pendingTickets,
@@ -107,7 +115,6 @@ func calculateGrowth(current, previous int64) float64 {
return float64(current-previous) / float64(previous) * 100.0
}
func AdminPlansFetch(c *gin.Context) {
var plans []model.Plan
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)
}
func AdminOrdersFetch(c *gin.Context) {
params := getFetchParams(c)
page := parsePositiveInt(params["page"], 1)
perPage := parsePositiveInt(params["per_page"], 50)
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
keyword := strings.TrimSpace(params["keyword"])
statusFilter := strings.TrimSpace(params["status"])
@@ -277,15 +283,28 @@ func AdminOrdersFetch(c *gin.Context) {
}
userEmails := loadUserEmailMap(extractOrderUserIDs(orders))
inviteEmails := loadUserEmailMap(extractOrderInviteUserIDs(orders))
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
item := normalizeOrder(order)
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)
}
Success(c, gin.H{
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
"status": statusFilter,
@@ -388,7 +407,6 @@ func AdminCouponDrop(c *gin.Context) {
Success(c, true)
}
func AdminOrderPaid(c *gin.Context) {
var payload struct {
TradeNo string `json:"trade_no"`
@@ -421,11 +439,6 @@ func AdminOrderPaid(c *gin.Context) {
return
}
// Update user
var user model.User
if err := tx.Where("id = ?", order.ID).First(&user).Error; err == nil {
// Calculate expiration and traffic
// Simplified logic: set plan and transfer_enable
updates := map[string]any{
"plan_id": order.PlanID,
"updated_at": now,
@@ -438,7 +451,6 @@ func AdminOrderPaid(c *gin.Context) {
Fail(c, http.StatusInternalServerError, "failed to update user")
return
}
}
tx.Commit()
Success(c, true)
@@ -564,8 +576,8 @@ func AdminUserUpdate(c *gin.Context) {
func AdminUsersFetch(c *gin.Context) {
params := getFetchParams(c)
page := parsePositiveInt(params["page"], 1)
perPage := parsePositiveInt(params["per_page"], 50)
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
keyword := strings.TrimSpace(params["keyword"])
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
@@ -591,6 +603,28 @@ func AdminUsersFetch(c *gin.Context) {
}
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()
items := make([]gin.H, 0, len(users))
for _, user := range users {
@@ -599,26 +633,57 @@ func AdminUsersFetch(c *gin.Context) {
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{
"id": user.ID,
"email": user.Email,
"parent_id": intValue(user.ParentID),
"is_shadow_user": user.ParentID != nil,
"balance": user.Balance,
"uuid": user.UUID,
"token": user.Token,
"group_id": intValue(user.GroupID),
"group_name": groupNames[intFromPointer(user.GroupID)],
"group": gin.H{
"id": intValue(user.GroupID),
"name": groupNames[intFromPointer(user.GroupID)],
},
"plan_id": intValue(user.PlanID),
"plan_name": planName(user.Plan),
"plan": gin.H{
"id": intValue(user.PlanID),
"name": planName(user.Plan),
},
"transfer_enable": user.TransferEnable,
"u": user.U,
"d": user.D,
"total_used": user.U + user.D,
"banned": user.Banned,
"is_admin": user.IsAdmin,
"is_staff": user.IsStaff,
"device_limit": intValue(user.DeviceLimit),
"online_count": intValue(user.OnlineCount),
"expired_at": int64Value(user.ExpiredAt),
"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),
@@ -628,8 +693,10 @@ func AdminUsersFetch(c *gin.Context) {
})
}
Success(c, gin.H{
c.JSON(http.StatusOK, gin.H{
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
},
@@ -690,6 +757,8 @@ func AdminTicketsFetch(c *gin.Context) {
Success(c, gin.H{
"list": items,
"data": items,
"total": total,
"filters": gin.H{
"keyword": keyword,
},
@@ -746,6 +815,16 @@ func extractOrderUserIDs(orders []model.Order) []int {
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 {
ids := make([]int, 0, len(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 {
parent = gin.H{
"id": parentServer.ID,
"code": stringValue(parentServer.Code),
"type": parentServer.Type,
"name": parentServer.Name,
"host": parentServer.Host,
@@ -581,11 +582,11 @@ func serializeAdminServer(server model.Server, groups map[int]model.ServerGroup,
isOnline = 2
}
availableStatus := "offline"
availableStatus := 0
if isOnline == 1 {
availableStatus = "online_no_push"
availableStatus = 1
} else if isOnline == 2 {
availableStatus = "online"
availableStatus = 2
}
hasChildren := false
@@ -862,4 +863,3 @@ func isAllowedRouteAction(action string) bool {
return false
}
}

View File

@@ -25,13 +25,18 @@ func AdminGetTrafficRank(c *gin.Context) {
if endTime == 0 {
endTime = time.Now().Unix()
}
periodDuration := endTime - startTime
if periodDuration <= 0 {
periodDuration = 86400
}
previousStart := startTime - periodDuration
previousEnd := startTime
var result []gin.H
if rankType == "user" {
type userRank struct {
ID int `json:"id"`
Value int64 `json:"value"`
Email string `json:"name"`
}
var ranks []userRank
database.DB.Model(&model.StatUser{}).
@@ -47,11 +52,15 @@ func AdminGetTrafficRank(c *gin.Context) {
userIDs = append(userIDs, r.ID)
}
userEmails := loadUserEmailMap(userIDs)
previousValues := loadUserPreviousTrafficMap(userIDs, previousStart, previousEnd)
for _, r := range ranks {
previousValue := previousValues[r.ID]
result = append(result, gin.H{
"id": fmt.Sprintf("%d", r.ID),
"name": userEmails[r.ID],
"value": r.Value,
"previousValue": previousValue,
"change": calculateGrowth(r.Value, previousValue),
})
}
} else {
@@ -75,11 +84,15 @@ func AdminGetTrafficRank(c *gin.Context) {
nodeIDs = append(nodeIDs, r.ID)
}
nodeNames := loadNodeNameMap(nodeIDs)
previousValues := loadNodePreviousTrafficMap(nodeIDs, previousStart, previousEnd)
for _, r := range ranks {
previousValue := previousValues[r.ID]
result = append(result, gin.H{
"id": fmt.Sprintf("%d", r.ID),
"name": nodeNames[r.ID],
"value": r.Value,
"previousValue": previousValue,
"change": calculateGrowth(r.Value, previousValue),
})
}
}
@@ -116,6 +129,10 @@ func AdminGetOrderStats(c *gin.Context) {
Find(&stats)
var list []gin.H
var paidTotal int64
var paidCount int64
var commissionTotal int64
var commissionCount int64
for _, s := range stats {
dateStr := time.Unix(s.RecordAt, 0).Format("2006-01-02")
item := gin.H{
@@ -127,16 +144,80 @@ func AdminGetOrderStats(c *gin.Context) {
item["paid_total"] = s.PaidTotal
item["paid_count"] = s.PaidCount
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)
}
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{
"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 {
result := make(map[int]string)

View File

@@ -99,10 +99,6 @@ func Register(c *gin.Context) {
return
}
if service.IsPluginEnabled(service.PluginUserAddIPv6) && user.PlanID != nil {
service.SyncIPv6ShadowAccount(&user)
}
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
if err != nil {
Fail(c, http.StatusInternalServerError, "failed to create auth token")
@@ -166,7 +162,29 @@ func SendEmailVerify(c *gin.Context) {
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{
"email": req.Email,
"debug_code": code,

View File

@@ -6,6 +6,7 @@ import (
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"xboard-go/pkg/utils"
"github.com/gin-gonic/gin"
)
@@ -16,7 +17,7 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
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 != "" {
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
}
@@ -35,33 +36,66 @@ func PluginUserOnlineDevicesUsers(c *gin.Context) {
userIDs = append(userIDs, user.ID)
}
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))
usersWithOnlineIP := 0
totalOnlineIPs := 0
for _, user := range users {
subscriptionName := "No subscription"
if user.PlanID != nil {
var plan model.Plan
if database.DB.First(&plan, *user.PlanID).Error == nil {
subscriptionName = plan.Name
ips := devices[user.ID]
meta := deviceMetaByUserID[user.ID]
onlineCount := len(ips)
if meta.Count > onlineCount {
onlineCount = meta.Count
}
if onlineCount > 0 {
usersWithOnlineIP++
totalOnlineIPs += onlineCount
}
ips := devices[user.ID]
if len(ips) > 0 {
usersWithOnlineIP++
totalOnlineIPs += len(ips)
lastOnlineText := formatTimeValue(user.LastOnlineAt)
if meta.LastSeenAt > 0 {
lastOnlineText = formatUnixValue(meta.LastSeenAt)
}
status := "offline"
statusLabel := "Offline"
if onlineCount > 0 {
status = "online"
statusLabel = "Online"
}
list = append(list, gin.H{
"id": user.ID,
"email": user.Email,
"subscription_name": subscriptionName,
"online_count": len(ips),
"subscription_name": planName(user.Plan),
"online_count": onlineCount,
"online_devices": ips,
"last_online_text": formatTimeValue(user.LastOnlineAt),
"last_online_text": lastOnlineText,
"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) {
user, ok := currentUser(c)
@@ -136,24 +334,41 @@ func PluginUserAddIPv6Check(c *gin.Context) {
return
}
if user.PlanID == nil {
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
return
var plan *model.Plan
if user.PlanID != nil {
var loadedPlan model.Plan
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil {
plan = &loadedPlan
}
}
var plan model.Plan
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
return
var subscription model.UserIPv6Subscription
hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil
allowed := service.PluginUserAllowed(user, plan)
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{
"allowed": service.PluginPlanAllowed(&plan),
"is_active": count > 0,
"allowed": allowed || hasSubscription && subscription.Allowed,
"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
}
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) {
@@ -181,13 +412,7 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
return
}
ipv6Email := service.IPv6ShadowEmail(user.Email)
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 {
if !service.SyncIPv6PasswordState(user) {
Fail(c, 404, "IPv6 user not found")
return
}
@@ -195,8 +420,6 @@ func PluginUserAddIPv6SyncPassword(c *gin.Context) {
SuccessMessage(c, "Password synced to IPv6 account", true)
}
func AdminSystemStatus(c *gin.Context) {
Success(c, gin.H{
"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).
// The React frontend polls this endpoint; returning empty data prevents 404 polling errors.
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{
"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{},
"masters": []any{},
"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 {
return nil, false
}
if service.IsPluginEnabled(service.PluginUserAddIPv6) && !strings.Contains(user.Email, "-ipv6@") && user.PlanID != nil {
service.SyncIPv6ShadowAccount(&user)
}
c.Set("user", &user)
return &user, true

View File

@@ -7,7 +7,9 @@ import (
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
@@ -27,6 +29,7 @@ type userThemeViewData struct {
type adminAppViewData struct {
Title string
SettingsJS template.JS
AssetNonce string
}
func UserThemePage(c *gin.Context) {
@@ -42,6 +45,7 @@ func UserThemePage(c *gin.Context) {
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
"icpNo": service.MustGetString("icp_no", ""),
"psbNo": service.MustGetString("psb_no", ""),
"staticCdnUrl": service.MustGetString("nebula_static_cdn_url", ""),
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
}
@@ -80,6 +84,7 @@ func AdminAppPage(c *gin.Context) {
payload := adminAppViewData{
Title: title,
SettingsJS: template.JS(settingsJSON),
AssetNonce: strconv.FormatInt(time.Now().UnixNano(), 10),
}
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"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
)
const deviceStateTTL = 10 * time.Minute
@@ -28,7 +29,10 @@ func SaveUserNodeDevices(userID, nodeID int, ips []string) error {
}
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 {
@@ -53,6 +57,9 @@ func GetUsersDevices(userIDs []int) map[int][]string {
}
}
sort.Strings(merged)
if len(merged) == 0 {
merged = loadUserOnlineDevicesFromDB(userID)
}
result[userID] = merged
}
@@ -66,6 +73,9 @@ func SetDevices(userID, nodeID int, ips []string) error {
indexKey := deviceStateUserIndexKey(userID)
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
if indexSnapshot == nil {
indexSnapshot = make(userDevicesSnapshot)
}
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
}
@@ -95,3 +105,109 @@ func deviceStateKey(userID, nodeID int) string {
func deviceStateUserIndexKey(userID int) string {
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 {
if user == nil || user.PlanID == nil {
if user == nil {
return false
}
var plan model.Plan
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
return false
var plan *model.Plan
if user.PlanID != nil {
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
}
ipv6Email := IPv6ShadowEmail(user.Email)
var ipv6User model.User
now := time.Now().Unix()
created := false
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
ipv6User = *user
ipv6User.ID = 0
@@ -102,6 +107,7 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.T = 0
ipv6User.ParentID = &user.ID
ipv6User.CreatedAt = now
created = true
}
ipv6User.Email = ipv6Email
@@ -118,20 +124,41 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.GroupID = &groupID
}
return database.DB.Save(&ipv6User).Error == nil
}
func PluginPlanAllowed(plan *model.Plan) bool {
if plan == nil {
if err := database.DB.Save(&ipv6User).Error; err != nil {
syncIPv6SubscriptionRecord(user, nil, true, "eligible")
return false
}
for _, raw := range strings.Split(GetPluginConfigString(PluginUserAddIPv6, "allowed_plans", ""), ",") {
if parsePluginPositiveInt(strings.TrimSpace(raw), 0) == plan.ID {
if created {
_ = 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
}
}
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"))
reference := ""
if plan.Reference != nil {
@@ -140,6 +167,68 @@ func PluginPlanAllowed(plan *model.Plan) bool {
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 {
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
parts := strings.SplitN(email, "@", 2)
@@ -156,3 +245,67 @@ func parsePluginPositiveInt(raw string, defaultValue int) int {
}
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"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"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].LastUsedAt = now
saveSessions(userID, list)
syncUserSessionOnlineState(userID, list[i].IP, now)
return list[i]
}
}
@@ -53,6 +55,7 @@ func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
}
list = append(list, record)
saveSessions(userID, list)
syncUserSessionOnlineState(userID, record.IP, now)
return record
}
@@ -177,6 +180,45 @@ func saveSessions(userID int, sessions []SessionRecord) {
_ = 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 {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])