基本功能已初步完善
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">→</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">→</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
54
frontend/admin/main.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const settings = window.settings || {};
|
||||
const assetNonce = window.__ADMIN_ASSET_NONCE__ || String(Date.now());
|
||||
const securePath = String(settings.secure_path || "admin").replace(/^\/+/, "");
|
||||
const adminBase = `/api/v2/${securePath}`;
|
||||
|
||||
window.ADMIN_APP_CONFIG = {
|
||||
title: settings.title || "XBoard Admin",
|
||||
version: settings.version || "1.0.0",
|
||||
securePath,
|
||||
baseUrl: settings.base_url || window.location.origin,
|
||||
api: {
|
||||
adminBase,
|
||||
adminConfig: `${adminBase}/config/fetch`,
|
||||
dashboardSummary: `${adminBase}/dashboard/summary`,
|
||||
systemStatus: `${adminBase}/system/getSystemStatus`,
|
||||
serverNodes: `${adminBase}/server/manage/getNodes`,
|
||||
serverGroups: `${adminBase}/server/group/fetch`,
|
||||
serverRoutes: `${adminBase}/server/route/fetch`,
|
||||
plans: `${adminBase}/plan/fetch`,
|
||||
orders: `${adminBase}/order/fetch`,
|
||||
coupons: `${adminBase}/coupon/fetch`,
|
||||
users: `${adminBase}/user/fetch`,
|
||||
tickets: `${adminBase}/ticket/fetch`,
|
||||
realnameBase: `${adminBase}/realname`,
|
||||
onlineDevices: `${adminBase}/user-online-devices/users`,
|
||||
ipv6Base: `${adminBase}/user-add-ipv6-subscription`,
|
||||
},
|
||||
};
|
||||
|
||||
document.documentElement.dataset.adminExecutionMode = "main-app";
|
||||
|
||||
function showBootError(error) {
|
||||
console.error("Failed to boot admin app", error);
|
||||
const root = document.getElementById("admin-app");
|
||||
if (root) {
|
||||
root.innerHTML =
|
||||
`<div style="padding:24px;font-family:system-ui,sans-serif;color:#b91c1c;">Admin app failed to load.<br>${String(
|
||||
error && error.message ? error.message : error || "Unknown error",
|
||||
)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
if (!event || !event.error) {
|
||||
return;
|
||||
}
|
||||
showBootError(event.error);
|
||||
});
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `/admin-assets/app.js?v=${encodeURIComponent(assetNonce)}`;
|
||||
script.defer = true;
|
||||
script.onerror = () => showBootError(new Error("Failed to load /admin-assets/app.js"));
|
||||
document.body.appendChild(script);
|
||||
@@ -195337,21 +195337,6 @@ const DQe =
|
||||
{ path: "subscribe-template", element: Q.jsx(LQe, {}) },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "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({}),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
226
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal file
226
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
|
||||
import {
|
||||
compactText,
|
||||
requestJson,
|
||||
} from "../../runtime/client.js";
|
||||
|
||||
// Wot: Wrapper structure for the config page
|
||||
function Wot({ children }) {
|
||||
return <div className="recovery-live-page" style={{ padding: '2rem' }}>{children}</div>;
|
||||
}
|
||||
|
||||
// Hot: Header component for sections or the page
|
||||
function Hot({ title, description }) {
|
||||
return (
|
||||
<header className="recovery-live-hero" style={{ marginBottom: '2.5rem', borderBottom: '1px solid var(--border-soft)', pb: '1.5rem' }}>
|
||||
<div style={{ maxWidth: '800px' }}>
|
||||
<p className="eyebrow" style={{ color: 'var(--brand-color)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Theme Customization
|
||||
</p>
|
||||
<h2 style={{ fontSize: '2.25rem', marginBottom: '0.75rem' }}>{title}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: '1.6' }}>{description}</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// zot: Setting Item (Zone) component for form fields
|
||||
function zot({ label, children, span = 1 }) {
|
||||
return (
|
||||
<div className={`recovery-table-card ${span === 2 ? 'span-2' : ''}`} style={{ padding: '1.25rem', background: 'var(--card-bg)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem', fontWeight: '500', color: 'var(--text-main)' }}>{label}</label>
|
||||
<div className="form-control-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QKt: The main Nebula configuration component
|
||||
function QKt() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
nebula_theme_color: 'aurora',
|
||||
nebula_hero_slogan: '',
|
||||
nebula_welcome_target: '',
|
||||
nebula_register_title: '',
|
||||
nebula_background_url: '',
|
||||
nebula_metrics_base_url: '',
|
||||
nebula_default_theme_mode: 'system',
|
||||
nebula_light_logo_url: '',
|
||||
nebula_dark_logo_url: '',
|
||||
nebula_custom_html: '',
|
||||
nebula_static_cdn_url: '',
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await requestJson("/config/fetch?key=nebula");
|
||||
if (payload?.nebula) {
|
||||
setForm(prev => ({ ...prev, ...payload.nebula }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load configuration: " + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSuccess("");
|
||||
setError("");
|
||||
try {
|
||||
await requestJson("/config/save", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
setSuccess("Nebula configuration updated successfully.");
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (err) {
|
||||
setError("Failed to save configuration: " + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Wot><div className="empty-cell">Initializing Nebula settings...</div></Wot>;
|
||||
|
||||
return (
|
||||
<Wot>
|
||||
<Hot
|
||||
title="Nebula Theme settings"
|
||||
description="Optimize your user dashboard with specific Nebula theme settings and branding options."
|
||||
/>
|
||||
|
||||
{error && <div className="toast toast-error" style={{ marginBottom: '1.5rem' }}>{error}</div>}
|
||||
{success && <div className="toast toast-success" style={{ marginBottom: '1.5rem' }}>{success}</div>}
|
||||
|
||||
<form onSubmit={handleSave} className="modal-grid" style={{ maxWidth: '1200px' }}>
|
||||
<zot label="Primary Accent Color" span={2}>
|
||||
<select
|
||||
value={form.nebula_theme_color}
|
||||
onChange={e => setForm({...form, nebula_theme_color: e.target.value})}
|
||||
className="recovery-input"
|
||||
style={{ width: '100%', height: '42px' }}
|
||||
>
|
||||
<option value="aurora">极光蓝 (Aurora Blue)</option>
|
||||
<option value="sunset">日落橙 (Sunset Orange)</option>
|
||||
<option value="ember">余烬红 (Ember Red)</option>
|
||||
<option value="violet">星云紫 (Violet Purple)</option>
|
||||
</select>
|
||||
</zot>
|
||||
|
||||
<zot label="Hero Slogan">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_hero_slogan}
|
||||
onChange={e => setForm({...form, nebula_hero_slogan: e.target.value})}
|
||||
placeholder="Main visual headline"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Welcome Target">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_welcome_target}
|
||||
onChange={e => setForm({...form, nebula_welcome_target: e.target.value})}
|
||||
placeholder="Name displayed after WELCOME TO"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Register Title">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_register_title}
|
||||
onChange={e => setForm({...form, nebula_register_title: e.target.value})}
|
||||
placeholder="Title on registration panel"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Default Appearance">
|
||||
<select
|
||||
value={form.nebula_default_theme_mode}
|
||||
onChange={e => setForm({...form, nebula_default_theme_mode: e.target.value})}
|
||||
className="recovery-input"
|
||||
style={{ width: '100%', height: '42px' }}
|
||||
>
|
||||
<option value="system">Adaptive (System)</option>
|
||||
<option value="dark">Dark Theme</option>
|
||||
<option value="light">Light Theme</option>
|
||||
</select>
|
||||
</zot>
|
||||
|
||||
<zot label="Background Image URL">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_background_url}
|
||||
onChange={e => setForm({...form, nebula_background_url: e.target.value})}
|
||||
placeholder="Direct link to background image"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Metrics API Domain">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_metrics_base_url}
|
||||
onChange={e => setForm({...form, nebula_metrics_base_url: e.target.value})}
|
||||
placeholder="https://stats.example.com"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Light Mode Logo">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_light_logo_url}
|
||||
onChange={e => setForm({...form, nebula_light_logo_url: e.target.value})}
|
||||
placeholder="Logo for light mode"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Dark Mode Logo">
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_dark_logo_url}
|
||||
onChange={e => setForm({...form, nebula_dark_logo_url: e.target.value})}
|
||||
placeholder="Logo for dark mode"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Static CDN Assets" span={2}>
|
||||
<input
|
||||
className="recovery-input"
|
||||
value={form.nebula_static_cdn_url}
|
||||
onChange={e => setForm({...form, nebula_static_cdn_url: e.target.value})}
|
||||
placeholder="e.g. https://cdn.example.com/nebula (no trailing slash)"
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<zot label="Custom Scripts / CSS" span={2}>
|
||||
<textarea
|
||||
className="recovery-input"
|
||||
rows={6}
|
||||
value={form.nebula_custom_html}
|
||||
onChange={e => setForm({...form, nebula_custom_html: e.target.value})}
|
||||
placeholder="Add analytics codes or custom styles here"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</zot>
|
||||
|
||||
<div className="span-2" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'flex-end', borderTop: '1px solid var(--border-soft)', paddingTop: '1.5rem' }}>
|
||||
<button type="submit" className="primary-btn" disabled={saving}>
|
||||
{saving ? "Persisting..." : "Save Configuration"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Wot>
|
||||
);
|
||||
}
|
||||
|
||||
export default QKt;
|
||||
@@ -7,14 +7,14 @@
|
||||
<script>
|
||||
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>
|
||||
|
||||
926
frontend/templates/admin_plugin_panel.html
Normal file
926
frontend/templates/admin_plugin_panel.html
Normal file
@@ -0,0 +1,926 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--muted-soft: #f8fafc;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #15803d;
|
||||
--warn-bg: #fef3c7;
|
||||
--warn-text: #b45309;
|
||||
--danger-bg: #fee2e2;
|
||||
--danger-text: #b91c1c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
position: relative;
|
||||
width: min(100%, 360px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
box-shadow: var(--shadow-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-trigger:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.search-trigger kbd {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--muted-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--muted-soft);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar-name,
|
||||
.avatar-email {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar-email {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field,
|
||||
.btn {
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
min-width: 260px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0 14px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-dark));
|
||||
}
|
||||
|
||||
.btn-warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
thead th {
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--muted-soft);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relation {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.pager-summary {
|
||||
flex: 1;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pager-controls {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pager-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 56px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pager-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.dialog-mask.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(680px, 100%);
|
||||
max-height: min(78vh, 720px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dialog-search {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-list {
|
||||
max-height: 56vh;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dialog-empty {
|
||||
padding: 28px 14px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--muted-soft);
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-path {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.pager {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pager-controls {
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<button id="open-menu-search" class="search-trigger" type="button">
|
||||
<span>搜索菜单和功能</span>
|
||||
<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<div class="topbar-right">
|
||||
<div class="avatar-card">
|
||||
<img id="user-avatar" class="avatar" alt="avatar" />
|
||||
<div id="avatar-fallback" class="avatar-fallback">AD</div>
|
||||
<div class="avatar-meta">
|
||||
<div id="user-name" class="avatar-name">Admin</div>
|
||||
<div id="user-email" class="avatar-email">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<input id="keyword" class="field" placeholder="搜索用户 ID / 邮箱" />
|
||||
</div>
|
||||
<div class="toolbar-right" id="toolbar-actions"></div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead id="thead"></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<div class="pager-summary" id="summary">加载中...</div>
|
||||
<div class="pager-controls">
|
||||
<div class="pager-page">
|
||||
<span>第</span>
|
||||
<input id="page-input" class="field page-input" inputmode="numeric" />
|
||||
<span id="page-total">/ 1 页</span>
|
||||
</div>
|
||||
<div class="pager-buttons">
|
||||
<button id="first-btn" class="btn icon-btn" aria-label="首页">«</button>
|
||||
<button id="prev-btn" class="btn icon-btn" aria-label="上一页">‹</button>
|
||||
<button id="next-btn" class="btn icon-btn" aria-label="下一页">›</button>
|
||||
<button id="last-btn" class="btn icon-btn" aria-label="末页">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="menu-dialog" class="dialog-mask" aria-hidden="true">
|
||||
<div class="dialog">
|
||||
<div class="dialog-head">
|
||||
<input id="menu-search-input" class="field dialog-search" placeholder="搜索菜单和功能" />
|
||||
</div>
|
||||
<div id="menu-list" class="dialog-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const kind = "{{.Kind}}";
|
||||
const securePath = "{{.SecurePath}}";
|
||||
const apiV1Base = "/api/v1";
|
||||
const apiV2Base = "/api/v2";
|
||||
const apiBase = `${apiV2Base}/${securePath}`;
|
||||
const state = { page: 1, lastPage: 1, total: 0, user: null };
|
||||
const menuItems = [
|
||||
{ title: "系统配置", path: "/config/system" },
|
||||
{ title: "公告管理", path: "/config/notice" },
|
||||
{ title: "知识库管理", path: "/config/knowledge" },
|
||||
{ title: "节点管理", path: "/server/manage" },
|
||||
{ title: "权限组管理", path: "/server/group" },
|
||||
{ title: "路由管理", path: "/server/route" },
|
||||
{ title: "套餐管理", path: "/finance/plan" },
|
||||
{ title: "订单管理", path: "/finance/order" },
|
||||
{ title: "用户管理", path: "/user/manage" },
|
||||
{ title: "工单管理", path: "/user/ticket" },
|
||||
{ title: "实名认证", path: "/user/realname" },
|
||||
{ title: "在线设备", path: "/user/online-devices" },
|
||||
{ title: "IPv6 子账号", path: "/user/ipv6-subscription" },
|
||||
];
|
||||
|
||||
function parseToken(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== "string") return "";
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const nested = parsed && (parsed.value || parsed.token || parsed.access_token || parsed.accessToken);
|
||||
if (nested) return String(nested).startsWith("Bearer ") ? nested : `Bearer ${nested}`;
|
||||
} catch (error) {}
|
||||
}
|
||||
return trimmed.startsWith("Bearer ") ? trimmed : `Bearer ${trimmed}`;
|
||||
}
|
||||
|
||||
function getAdminToken() {
|
||||
const keys = [
|
||||
"XBOARD_ACCESS_TOKEN",
|
||||
"Xboard_access_token",
|
||||
"XBOARD_ADMIN_ACCESS_TOKEN",
|
||||
"xboard_access_token",
|
||||
"__gopanel_admin_auth__",
|
||||
];
|
||||
for (const storage of [window.localStorage, window.sessionStorage]) {
|
||||
if (!storage) continue;
|
||||
for (const key of keys) {
|
||||
const token = parseToken(storage.getItem(key));
|
||||
if (token) return token;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function request(url, method = "GET", body) {
|
||||
const token = getAdminToken();
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error((payload && (payload.message || payload.msg)) || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload && typeof payload.data !== "undefined" ? payload.data : payload;
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const user = await request(`${apiV2Base}/user/info`);
|
||||
state.user = user || {};
|
||||
const email = state.user.email || "admin@example.com";
|
||||
const name = email.includes("@") ? email.split("@")[0] : email;
|
||||
const initials = (name || "AD").slice(0, 2).toUpperCase();
|
||||
document.getElementById("user-name").textContent = name || "Admin";
|
||||
document.getElementById("user-email").textContent = email;
|
||||
document.getElementById("avatar-fallback").textContent = initials;
|
||||
|
||||
const avatar = document.getElementById("user-avatar");
|
||||
if (state.user.avatar_url) {
|
||||
avatar.src = state.user.avatar_url;
|
||||
avatar.style.display = "block";
|
||||
} else {
|
||||
avatar.style.display = "none";
|
||||
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||
}
|
||||
avatar.onerror = () => {
|
||||
avatar.style.display = "none";
|
||||
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||
};
|
||||
avatar.onload = () => {
|
||||
avatar.style.display = "block";
|
||||
document.getElementById("avatar-fallback").style.display = "none";
|
||||
};
|
||||
} catch (error) {
|
||||
document.getElementById("user-email").textContent = "管理员";
|
||||
document.getElementById("avatar-fallback").style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(text) {
|
||||
const normalized = String(text || "-").toLowerCase();
|
||||
let cls = "danger";
|
||||
if (/(approved|active|enabled|ready)/.test(normalized)) cls = "ok";
|
||||
else if (/(pending|eligible|unverified)/.test(normalized)) cls = "warn";
|
||||
return `<span class="badge ${cls}">${text || "-"}</span>`;
|
||||
}
|
||||
|
||||
function relationChip(left, right) {
|
||||
return `<span class="relation mono">${left}<span>→</span>${right}</span>`;
|
||||
}
|
||||
|
||||
function setPagination(pagination) {
|
||||
state.page = pagination.current || 1;
|
||||
state.lastPage = pagination.last_page || 1;
|
||||
state.total = pagination.total || 0;
|
||||
document.getElementById("summary").textContent = `共 ${state.total} 条数据`;
|
||||
document.getElementById("page-input").value = String(state.page);
|
||||
document.getElementById("page-total").textContent = `/ ${state.lastPage} 页`;
|
||||
document.getElementById("first-btn").disabled = state.page <= 1;
|
||||
document.getElementById("prev-btn").disabled = state.page <= 1;
|
||||
document.getElementById("next-btn").disabled = state.page >= state.lastPage;
|
||||
document.getElementById("last-btn").disabled = state.page >= state.lastPage;
|
||||
}
|
||||
|
||||
function jumpToPage(value) {
|
||||
const nextPage = Number.parseInt(String(value || "").trim(), 10);
|
||||
if (Number.isNaN(nextPage)) {
|
||||
document.getElementById("page-input").value = String(state.page);
|
||||
return;
|
||||
}
|
||||
const targetPage = Math.min(Math.max(nextPage, 1), Math.max(state.lastPage, 1));
|
||||
loadData(targetPage);
|
||||
}
|
||||
|
||||
function setupToolbar() {
|
||||
const toolbar = document.getElementById("toolbar-actions");
|
||||
if (kind === "realname") {
|
||||
toolbar.innerHTML = `
|
||||
<button class="btn btn-warn" id="sync-all-btn">同步全部</button>
|
||||
<button class="btn btn-primary" id="approve-all-btn">全部通过</button>
|
||||
`;
|
||||
document.getElementById("sync-all-btn").onclick = async () => {
|
||||
await request(`${apiBase}/realname/sync-all`, "POST", {});
|
||||
await loadData(state.page);
|
||||
};
|
||||
document.getElementById("approve-all-btn").onclick = async () => {
|
||||
await request(`${apiBase}/realname/approve-all`, "POST", {});
|
||||
await loadData(state.page);
|
||||
};
|
||||
} else {
|
||||
toolbar.innerHTML = `<button class="btn" id="refresh-btn">刷新</button>`;
|
||||
document.getElementById("refresh-btn").onclick = () => loadData(state.page);
|
||||
}
|
||||
}
|
||||
|
||||
function endpoint(page) {
|
||||
const keyword = encodeURIComponent(document.getElementById("keyword").value.trim());
|
||||
if (kind === "realname") return `${apiBase}/realname/records?page=${page}&per_page=20&keyword=${keyword}`;
|
||||
if (kind === "online-devices") return `${apiBase}/user-online-devices/users?page=${page}&per_page=20&keyword=${keyword}`;
|
||||
return `${apiBase}/user-add-ipv6-subscription/users?page=${page}&per_page=20&keyword=${keyword}`;
|
||||
}
|
||||
|
||||
function renderRealname(payload) {
|
||||
const list = payload.data || [];
|
||||
document.getElementById("thead").innerHTML = `
|
||||
<tr>
|
||||
<th>用户 ID</th>
|
||||
<th>邮箱</th>
|
||||
<th>姓名</th>
|
||||
<th>证件号</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
`;
|
||||
document.getElementById("tbody").innerHTML = list.length
|
||||
? list
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td class="mono">${item.id}</td>
|
||||
<td>${item.email || "-"}</td>
|
||||
<td>${item.real_name || "-"}</td>
|
||||
<td class="mono">${item.identity_no_masked || "-"}</td>
|
||||
<td>${statusBadge(item.status_label || item.status)}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="realnameReview(${item.id}, 'approved')">通过</button>
|
||||
<button class="btn" onclick="realnameReview(${item.id}, 'rejected')">驳回</button>
|
||||
<button class="btn" onclick="realnameReset(${item.id})">重置</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||
}
|
||||
|
||||
function renderOnlineDevices(payload) {
|
||||
const list = payload.list || [];
|
||||
document.getElementById("thead").innerHTML = `
|
||||
<tr>
|
||||
<th>用户 ID</th>
|
||||
<th>邮箱</th>
|
||||
<th>套餐</th>
|
||||
<th>在线 IP</th>
|
||||
<th>数量</th>
|
||||
<th>最后在线</th>
|
||||
</tr>
|
||||
`;
|
||||
document.getElementById("tbody").innerHTML = list.length
|
||||
? list
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td class="mono">${item.id}</td>
|
||||
<td>${item.email || "-"}</td>
|
||||
<td>${item.subscription_name || "-"}</td>
|
||||
<td>${(item.online_devices || []).join("<br>") || "-"}</td>
|
||||
<td class="mono">${item.online_count || 0}</td>
|
||||
<td>${item.last_online_text || "-"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||
}
|
||||
|
||||
function renderIPv6(payload) {
|
||||
const list = payload.list || [];
|
||||
document.getElementById("thead").innerHTML = `
|
||||
<tr>
|
||||
<th>主从关系</th>
|
||||
<th>主账号</th>
|
||||
<th>IPv6 账号</th>
|
||||
<th>套餐</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
`;
|
||||
document.getElementById("tbody").innerHTML = list.length
|
||||
? list
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td>${relationChip(item.id, item.shadow_user_id || "-")}</td>
|
||||
<td>${item.email || "-"}</td>
|
||||
<td class="mono">${item.ipv6_email || "-"}</td>
|
||||
<td>${item.plan_name || "-"}</td>
|
||||
<td>${statusBadge(item.status_label || item.status)}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="ipv6Enable(${item.id})">开通并同步</button>
|
||||
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||
return payload.pagination || { current: 1, last_page: 1, total: list.length };
|
||||
}
|
||||
|
||||
async function loadData(page = 1) {
|
||||
const payload = await request(endpoint(page));
|
||||
const pagination =
|
||||
kind === "realname"
|
||||
? renderRealname(payload)
|
||||
: kind === "online-devices"
|
||||
? renderOnlineDevices(payload)
|
||||
: renderIPv6(payload);
|
||||
setPagination(pagination);
|
||||
}
|
||||
|
||||
async function realnameReview(id, status) {
|
||||
const reason = status === "rejected" ? window.prompt("请输入驳回原因", "") || "" : "";
|
||||
await request(`${apiBase}/realname/review/${id}`, "POST", { status, reason });
|
||||
await loadData(state.page);
|
||||
}
|
||||
|
||||
async function realnameReset(id) {
|
||||
await request(`${apiBase}/realname/reset/${id}`, "POST", {});
|
||||
await loadData(state.page);
|
||||
}
|
||||
|
||||
async function ipv6Enable(id) {
|
||||
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
|
||||
await loadData(state.page);
|
||||
}
|
||||
|
||||
async function ipv6SyncPassword(id) {
|
||||
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
|
||||
await loadData(state.page);
|
||||
}
|
||||
|
||||
function openMenuDialog() {
|
||||
document.getElementById("menu-dialog").classList.add("is-open");
|
||||
document.getElementById("menu-dialog").setAttribute("aria-hidden", "false");
|
||||
document.getElementById("menu-search-input").focus();
|
||||
renderMenuList(document.getElementById("menu-search-input").value);
|
||||
}
|
||||
|
||||
function closeMenuDialog() {
|
||||
document.getElementById("menu-dialog").classList.remove("is-open");
|
||||
document.getElementById("menu-dialog").setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function navigateTo(path) {
|
||||
const target = `/${securePath}${path}`;
|
||||
if (window.top && window.top !== window) {
|
||||
window.top.location.assign(target);
|
||||
} else {
|
||||
window.location.assign(target);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMenuList(keyword = "") {
|
||||
const list = document.getElementById("menu-list");
|
||||
const normalized = String(keyword || "").trim().toLowerCase();
|
||||
const filtered = menuItems.filter((item) => {
|
||||
const haystack = `${item.title} ${item.path}`.toLowerCase();
|
||||
return !normalized || haystack.includes(normalized);
|
||||
});
|
||||
list.innerHTML = filtered.length
|
||||
? filtered
|
||||
.map(
|
||||
(item) => `
|
||||
<button class="menu-item" type="button" data-path="${item.path}">
|
||||
<div>
|
||||
<div class="menu-title">${item.title}</div>
|
||||
<div class="menu-path">${item.path}</div>
|
||||
</div>
|
||||
<span class="menu-path">进入</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("")
|
||||
: `<div class="dialog-empty">没有匹配的菜单或功能</div>`;
|
||||
}
|
||||
|
||||
document.getElementById("open-menu-search").addEventListener("click", openMenuDialog);
|
||||
document.getElementById("menu-dialog").addEventListener("click", (event) => {
|
||||
if (event.target.id === "menu-dialog") {
|
||||
closeMenuDialog();
|
||||
}
|
||||
});
|
||||
document.getElementById("menu-search-input").addEventListener("input", (event) => {
|
||||
renderMenuList(event.target.value);
|
||||
});
|
||||
document.getElementById("menu-list").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-path]");
|
||||
if (!button) return;
|
||||
closeMenuDialog();
|
||||
navigateTo(button.getAttribute("data-path"));
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
openMenuDialog();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
closeMenuDialog();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("keyword").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
loadData(1);
|
||||
}
|
||||
});
|
||||
document.getElementById("page-input").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
jumpToPage(event.target.value);
|
||||
}
|
||||
});
|
||||
document.getElementById("page-input").addEventListener("blur", (event) => {
|
||||
jumpToPage(event.target.value);
|
||||
});
|
||||
document.getElementById("first-btn").onclick = () => state.page > 1 && loadData(1);
|
||||
document.getElementById("prev-btn").onclick = () => state.page > 1 && loadData(state.page - 1);
|
||||
document.getElementById("next-btn").onclick = () => state.page < state.lastPage && loadData(state.page + 1);
|
||||
document.getElementById("last-btn").onclick = () => state.page < state.lastPage && loadData(state.lastPage);
|
||||
|
||||
setupToolbar();
|
||||
loadCurrentUser();
|
||||
loadData().catch((error) => {
|
||||
document.getElementById("summary").textContent = error.message || "加载失败";
|
||||
document.getElementById("page-input").value = "1";
|
||||
document.getElementById("page-total").textContent = "/ 1 页";
|
||||
document.getElementById("tbody").innerHTML = `<tr><td colspan="6" class="empty">${error.message || "加载失败"}</td></tr>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -34,6 +34,7 @@
|
||||
ipv6AuthToken: getStoredIpv6Token(),
|
||||
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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ">")
|
||||
}
|
||||
|
||||
@@ -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())))
|
||||
|
||||
37
internal/handler/admin_plugin_page.go
Normal file
37
internal/handler/admin_plugin_page.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type adminPluginPageViewData struct {
|
||||
Title string
|
||||
Kind string
|
||||
KindLabel string
|
||||
SecurePath string
|
||||
}
|
||||
|
||||
func AdminPluginPanelPage(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
labels := map[string]string{
|
||||
"realname": "实名认证",
|
||||
"online-devices": "在线 IP 统计",
|
||||
"ipv6-subscription": "IPv6 子账号",
|
||||
}
|
||||
label, ok := labels[kind]
|
||||
if !ok {
|
||||
c.String(http.StatusNotFound, "plugin panel not found")
|
||||
return
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_plugin_panel.html"), adminPluginPageViewData{
|
||||
Title: service.MustGetString("app_name", "XBoard") + " - " + label,
|
||||
Kind: kind,
|
||||
KindLabel: label,
|
||||
SecurePath: service.GetAdminSecurePath(),
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ func AdminDashboardSummary(c *gin.Context) {
|
||||
var totalOrders int64
|
||||
var 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
internal/model/user_ipv6_subscription.go
Normal file
21
internal/model/user_ipv6_subscription.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
type UserIPv6Subscription struct {
|
||||
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id;uniqueIndex:idx_user_ipv6_subscription_user" json:"user_id"`
|
||||
ShadowUserID *int `gorm:"column:shadow_user_id;index:idx_user_ipv6_subscription_shadow" json:"shadow_user_id"`
|
||||
IPv6Email string `gorm:"column:ipv6_email;size:191" json:"ipv6_email"`
|
||||
Allowed bool `gorm:"column:allowed;default:false" json:"allowed"`
|
||||
Status string `gorm:"column:status;size:32;index:idx_user_ipv6_subscription_status" json:"status"`
|
||||
LastSyncAt int64 `gorm:"column:last_sync_at" json:"last_sync_at"`
|
||||
PasswordSyncedAt *int64 `gorm:"column:password_synced_at" json:"password_synced_at"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
|
||||
ShadowUser *User `gorm:"foreignKey:ShadowUserID;references:ID" json:"shadow_user"`
|
||||
}
|
||||
|
||||
func (UserIPv6Subscription) TableName() string {
|
||||
return "v2_user_ipv6_subscriptions"
|
||||
}
|
||||
19
internal/model/user_online_device.go
Normal file
19
internal/model/user_online_device.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
type UserOnlineDevice struct {
|
||||
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id;index:idx_user_online_devices_user;uniqueIndex:idx_user_online_devices_unique" json:"user_id"`
|
||||
NodeID int `gorm:"column:node_id;index:idx_user_online_devices_node;uniqueIndex:idx_user_online_devices_unique" json:"node_id"`
|
||||
IP string `gorm:"column:ip;size:191;uniqueIndex:idx_user_online_devices_unique" json:"ip"`
|
||||
FirstSeenAt int64 `gorm:"column:first_seen_at" json:"first_seen_at"`
|
||||
LastSeenAt int64 `gorm:"column:last_seen_at;index:idx_user_online_devices_last_seen" json:"last_seen_at"`
|
||||
ExpiresAt int64 `gorm:"column:expires_at;index:idx_user_online_devices_expires" json:"expires_at"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID;references:ID" json:"user"`
|
||||
}
|
||||
|
||||
func (UserOnlineDevice) TableName() string {
|
||||
return "v2_user_online_devices"
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"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
283
internal/service/email.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Encryption string
|
||||
FromAddress string
|
||||
FromName string
|
||||
TemplateName string
|
||||
}
|
||||
|
||||
type EmailMessage struct {
|
||||
To []string
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
}
|
||||
|
||||
func LoadEmailConfig() EmailConfig {
|
||||
fromAddress := strings.TrimSpace(MustGetString("email_from_address", ""))
|
||||
if fromAddress == "" {
|
||||
fromAddress = strings.TrimSpace(MustGetString("email_from", ""))
|
||||
}
|
||||
|
||||
fromName := strings.TrimSpace(MustGetString("email_from_name", ""))
|
||||
if fromName == "" {
|
||||
fromName = strings.TrimSpace(MustGetString("app_name", "XBoard"))
|
||||
}
|
||||
|
||||
return EmailConfig{
|
||||
Host: strings.TrimSpace(MustGetString("email_host", "")),
|
||||
Port: MustGetInt("email_port", 465),
|
||||
Username: strings.TrimSpace(MustGetString("email_username", "")),
|
||||
Password: MustGetString("email_password", ""),
|
||||
Encryption: normalizeEmailEncryption(MustGetString("email_encryption", "")),
|
||||
FromAddress: fromAddress,
|
||||
FromName: fromName,
|
||||
TemplateName: firstNonEmpty(strings.TrimSpace(MustGetString("email_template", "")), "classic"),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg EmailConfig) DebugConfig() map[string]any {
|
||||
return map[string]any{
|
||||
"driver": "smtp",
|
||||
"host": cfg.Host,
|
||||
"port": cfg.Port,
|
||||
"encryption": cfg.Encryption,
|
||||
"username": cfg.Username,
|
||||
"from_address": cfg.SenderAddress(),
|
||||
"from_name": cfg.FromName,
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg EmailConfig) SenderAddress() string {
|
||||
return firstNonEmpty(strings.TrimSpace(cfg.FromAddress), strings.TrimSpace(cfg.Username))
|
||||
}
|
||||
|
||||
func (cfg EmailConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Host) == "":
|
||||
return errors.New("email_host is required")
|
||||
case cfg.Port <= 0:
|
||||
return errors.New("email_port is required")
|
||||
case cfg.SenderAddress() == "":
|
||||
return errors.New("email_from_address or email_username is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SendMailWithCurrentSettings(message EmailMessage) error {
|
||||
return SendMail(LoadEmailConfig(), message)
|
||||
}
|
||||
|
||||
func SendMail(cfg EmailConfig, message EmailMessage) error {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(message.To) == 0 {
|
||||
return errors.New("at least one recipient is required")
|
||||
}
|
||||
if strings.TrimSpace(message.Subject) == "" {
|
||||
return errors.New("subject is required")
|
||||
}
|
||||
if strings.TrimSpace(message.TextBody) == "" && strings.TrimSpace(message.HTMLBody) == "" {
|
||||
return errors.New("message body is required")
|
||||
}
|
||||
|
||||
payload, err := buildEmailMessage(cfg, message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := dialSMTP(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if cfg.Username != "" && cfg.Password != "" {
|
||||
if ok, _ := client.Extension("AUTH"); !ok {
|
||||
return errors.New("smtp server does not support authentication")
|
||||
}
|
||||
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(cfg.SenderAddress()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range message.To {
|
||||
recipient = strings.TrimSpace(recipient)
|
||||
if recipient == "" {
|
||||
continue
|
||||
}
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func dialSMTP(cfg EmailConfig) (*smtp.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
switch cfg.Encryption {
|
||||
case "ssl":
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return smtp.NewClient(conn, cfg.Host)
|
||||
case "tls":
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
_ = client.Close()
|
||||
return nil, errors.New("smtp server does not support STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
default:
|
||||
return smtp.Dial(addr)
|
||||
}
|
||||
}
|
||||
|
||||
func buildEmailMessage(cfg EmailConfig, message EmailMessage) ([]byte, error) {
|
||||
fromAddress := cfg.SenderAddress()
|
||||
if _, err := mail.ParseAddress(fromAddress); err != nil {
|
||||
return nil, fmt.Errorf("invalid sender address: %w", err)
|
||||
}
|
||||
|
||||
toAddresses := make([]string, 0, len(message.To))
|
||||
for _, recipient := range message.To {
|
||||
recipient = strings.TrimSpace(recipient)
|
||||
if recipient == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := mail.ParseAddress(recipient); err != nil {
|
||||
return nil, fmt.Errorf("invalid recipient address: %w", err)
|
||||
}
|
||||
toAddresses = append(toAddresses, recipient)
|
||||
}
|
||||
if len(toAddresses) == 0 {
|
||||
return nil, errors.New("no valid recipients")
|
||||
}
|
||||
|
||||
fromHeader := (&mail.Address{Name: cfg.FromName, Address: fromAddress}).String()
|
||||
subjectHeader := mime.QEncoding.Encode("UTF-8", strings.TrimSpace(message.Subject))
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeHeader := func(key, value string) {
|
||||
buf.WriteString(key)
|
||||
buf.WriteString(": ")
|
||||
buf.WriteString(value)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
writeHeader("From", fromHeader)
|
||||
writeHeader("To", strings.Join(toAddresses, ", "))
|
||||
writeHeader("Subject", subjectHeader)
|
||||
writeHeader("MIME-Version", "1.0")
|
||||
|
||||
textBody := message.TextBody
|
||||
htmlBody := message.HTMLBody
|
||||
|
||||
if strings.TrimSpace(htmlBody) != "" {
|
||||
mw := multipart.NewWriter(&buf)
|
||||
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, mw.Boundary()))
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
if strings.TrimSpace(textBody) == "" {
|
||||
textBody = htmlBody
|
||||
}
|
||||
|
||||
if err := writeMIMEPart(mw, "text/plain", textBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeMIMEPart(mw, "text/html", htmlBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
writeHeader("Content-Type", `text/plain; charset="UTF-8"`)
|
||||
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
qp := quotedprintable.NewWriter(&buf)
|
||||
if _, err := qp.Write([]byte(textBody)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := qp.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeMIMEPart(mw *multipart.Writer, contentType string, body string) error {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Set("Content-Type", fmt.Sprintf(`%s; charset="UTF-8"`, contentType))
|
||||
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
|
||||
part, err := mw.CreatePart(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
qp := quotedprintable.NewWriter(part)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
return qp.Close()
|
||||
}
|
||||
|
||||
func normalizeEmailEncryption(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "ssl":
|
||||
return "ssl"
|
||||
case "tls", "starttls":
|
||||
return "tls"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -76,21 +76,26 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
|
||||
}
|
||||
|
||||
func SyncIPv6ShadowAccount(user *model.User) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
|
||||
Reference in New Issue
Block a user