基本功能已初步完善
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user