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

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

View File

@@ -248,6 +248,26 @@ tbody tr:hover {
border-bottom: 1px solid var(--border);
}
.relation-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: #fafafa;
white-space: nowrap;
}
.relation-chip code {
font-size: 12px;
}
.relation-arrow {
color: var(--accent);
font-weight: 700;
}
/* Buttons */
.btn {
display: inline-flex;

View File

@@ -11,7 +11,7 @@
const state = {
token: readToken(),
user: null,
route: normalizeRoute(readRoute()),
route: "overview",
busy: false,
message: "",
messageType: "info",
@@ -38,6 +38,7 @@
tickets: { list: [], pagination: null },
realname: { list: [], pagination: null },
devices: { list: [], pagination: null },
ipv6: { list: [], pagination: null },
expandedNodes: new Set()
};
@@ -54,9 +55,12 @@
"ticket-manage": { title: "工单中心", description: "查看用户工单和处理状态。" },
realname: { title: "实名认证", description: "审核实名记录和同步状态。" },
"user-online-devices": { title: "在线设备", description: "查看用户在线 IP 和设备分布。" },
"user-ipv6-subscription": { title: "IPv6 子账号", description: "管理 IPv6 阴影账号与密码同步。" },
"system-config": { title: "系统设置", description: "编辑站点、订阅和安全参数。" }
};
state.route = normalizeRoute(readRoute());
boot();
async function boot() {
@@ -146,7 +150,7 @@
state.plans = toArray(unwrap(plans));
state.groups = toArray(unwrap(groups));
} else if (state.route === "order-manage") {
const payload = unwrap(await request(`${cfg.api.orders}?page=${page}&per_page=20`));
const payload = await request(`${cfg.api.orders}?page=${page}&per_page=20`);
state.orders = normalizeListPayload(payload);
} else if (state.route === "coupon-manage") {
state.coupons = toArray(unwrap(await request(cfg.api.coupons)));
@@ -156,18 +160,21 @@
request(cfg.api.plans),
request(cfg.api.serverGroups)
]);
state.users = normalizeListPayload(unwrap(users));
state.users = normalizeListPayload(users);
state.plans = toArray(unwrap(plans));
state.groups = toArray(unwrap(groups));
} else if (state.route === "ticket-manage") {
const payload = unwrap(await request(`${cfg.api.tickets}?page=${page}&per_page=20`));
const payload = await request(`${cfg.api.tickets}?page=${page}&per_page=20`);
state.tickets = normalizeListPayload(payload);
} else if (state.route === "realname") {
const payload = unwrap(await request(`${cfg.api.realnameBase}/records?page=${page}&per_page=20`));
const payload = await request(`${cfg.api.realnameBase}/records?page=${page}&per_page=20`);
state.realname = normalizeListPayload(payload, "data");
} else if (state.route === "user-online-devices") {
const payload = unwrap(await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`));
const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`);
state.devices = normalizeListPayload(payload);
} else if (state.route === "user-ipv6-subscription") {
const payload = await request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`);
state.ipv6 = normalizeListPayload(payload);
} else if (state.route === "system-config") {
state.config = unwrap(await request(cfg.api.adminConfig));
}
@@ -426,6 +433,21 @@
if (action === "sync-all") {
await adminPost(`${cfg.api.realnameBase}/sync-all`, {});
await hydrateRoute();
return;
}
if (action === "ipv6-enable") {
const userId = actionEl.getAttribute("data-user-id");
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, {});
await hydrateRoute();
return;
}
if (action === "ipv6-sync-password") {
const userId = actionEl.getAttribute("data-user-id");
await adminPost(`${cfg.api.ipv6Base}/sync-password/${userId}`, {});
await hydrateRoute();
return;
}
}
@@ -441,7 +463,7 @@
if (action === "login") {
try {
setBusy(true);
const payload = unwrap(await request("/api/v1/passport/auth/login", {
const payload = unwrap(await request("/api/v2/passport/auth/login", {
method: "POST",
auth: false,
body: serializeForm(form)
@@ -571,6 +593,7 @@
navItem("user-manage", "用户管理", "账号、订阅、流量"),
navItem("realname", "实名认证", "实名审核"),
navItem("user-online-devices", "在线设备", "在线 IP 与会话"),
navItem("user-ipv6-subscription", "IPv6 子账号", "开通与密码同步"),
navItem("ticket-manage", "工单中心", "用户支持")
]),
renderSidebarGroup("system", "系统", [
@@ -643,6 +666,7 @@
if (state.route === "ticket-manage") return renderTicketManage();
if (state.route === "realname") return renderRealnameManage();
if (state.route === "user-online-devices") return renderOnlineDevices();
if (state.route === "user-ipv6-subscription") return renderIPv6Manage();
if (state.route === "system-config") return renderSystemConfig();
return renderOverview();
}
@@ -719,10 +743,13 @@
const toggle = hasChildren
? `<button class="node-toggle" data-action="node-expand" data-id="${node.id}">${expanded ? "" : "+"}</button>`
: '<span class="node-toggle node-toggle-placeholder"></span>';
const relationCell = isChild && node.parent_id
? `<span class="relation-chip"><code>${escapeHtml(node.id)}</code><span class="relation-arrow">&rarr;</span><code>${escapeHtml(node.parent_id)}</code></span>`
: `<code>${node.id}</code>`;
return {
className: isChild ? "node-child" : "",
cells: [
`<code>${node.id}</code>`,
relationCell,
[
'<div class="node-name-block">',
toggle,
@@ -843,10 +870,13 @@
function renderUserManage() {
const rows = state.users.list.map((user) => [
`<code>${user.id}</code>`,
user.parent_id ? renderRelationChip(user.parent_id, user.id) : `<code>${user.id}</code>`,
[
`<strong>${escapeHtml(user.email || "-")}</strong>`,
user.online_ip ? `<div class="subtle-text">在线 IP: ${escapeHtml(user.online_ip)}</div>` : ""
user.parent_id ? `<div class="subtle-text">${renderRelationChip(user.parent_id, user.id)}</div>` : "",
user.online_ip ? `<div class="subtle-text">在线 IP: ${escapeHtml(user.online_ip)}</div>` : "",
`<div class="subtle-text">实名: ${escapeHtml(user.realname_label || user.realname_status || "-")}</div>`,
user.ipv6_enabled ? `<div class="subtle-text">IPv6: ${renderRelationChip(user.id, user.ipv6_shadow_id)}</div>` : ""
].join(""),
renderStatus(user.banned ? "banned" : "active"),
escapeHtml(`${formatTraffic((user.u || 0) + (user.d || 0))} / ${formatTraffic(user.transfer_enable)}`),
@@ -922,6 +952,28 @@
].join("");
}
function renderIPv6Manage() {
const rows = state.ipv6.list.map((item) => [
renderRelationChip(item.id, item.shadow_user_id || "-"),
[
`<strong>${escapeHtml(item.email || "-")}</strong>`,
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>`
].join(""),
escapeHtml(item.plan_name || "-"),
renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)),
renderActionRow([
buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`),
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`)
])
]);
return [
wrapTable(["主从关系", "账号", "套餐", "状态", "更新时间", "操作"], rows),
renderPagination(state.ipv6.pagination)
].join("");
}
function renderSystemConfig() {
const sections = ["site", "subscribe", "server", "safe", "invite", "frontend"];
const activeTab = sections.includes(state.configTab) ? state.configTab : "site";
@@ -1163,6 +1215,10 @@
return `<div class="row-actions">${items.join("")}</div>`;
}
function renderRelationChip(fromId, toId) {
return `<span class="relation-chip"><code>${escapeHtml(fromId)}</code><span class="relation-arrow">&rarr;</span><code>${escapeHtml(toId)}</code></span>`;
}
function hiddenField(name, value) {
return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(String(value))}" />`;
}
@@ -1432,9 +1488,9 @@
const text = String(status || "-");
const normalized = text.toLowerCase();
let type = "danger";
if (/(ok|active|visible|approved|paid|answered|online)/.test(normalized)) {
if (/(ok|active|visible|approved|paid|answered|online|enabled)/.test(normalized)) {
type = "ok";
} else if (/(pending|warn|unverified)/.test(normalized)) {
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
type = "warn";
}
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;

54
frontend/admin/main.js Normal file
View File

@@ -0,0 +1,54 @@
const settings = window.settings || {};
const assetNonce = window.__ADMIN_ASSET_NONCE__ || String(Date.now());
const securePath = String(settings.secure_path || "admin").replace(/^\/+/, "");
const adminBase = `/api/v2/${securePath}`;
window.ADMIN_APP_CONFIG = {
title: settings.title || "XBoard Admin",
version: settings.version || "1.0.0",
securePath,
baseUrl: settings.base_url || window.location.origin,
api: {
adminBase,
adminConfig: `${adminBase}/config/fetch`,
dashboardSummary: `${adminBase}/dashboard/summary`,
systemStatus: `${adminBase}/system/getSystemStatus`,
serverNodes: `${adminBase}/server/manage/getNodes`,
serverGroups: `${adminBase}/server/group/fetch`,
serverRoutes: `${adminBase}/server/route/fetch`,
plans: `${adminBase}/plan/fetch`,
orders: `${adminBase}/order/fetch`,
coupons: `${adminBase}/coupon/fetch`,
users: `${adminBase}/user/fetch`,
tickets: `${adminBase}/ticket/fetch`,
realnameBase: `${adminBase}/realname`,
onlineDevices: `${adminBase}/user-online-devices/users`,
ipv6Base: `${adminBase}/user-add-ipv6-subscription`,
},
};
document.documentElement.dataset.adminExecutionMode = "main-app";
function showBootError(error) {
console.error("Failed to boot admin app", error);
const root = document.getElementById("admin-app");
if (root) {
root.innerHTML =
`<div style="padding:24px;font-family:system-ui,sans-serif;color:#b91c1c;">Admin app failed to load.<br>${String(
error && error.message ? error.message : error || "Unknown error",
)}</div>`;
}
}
window.addEventListener("error", (event) => {
if (!event || !event.error) {
return;
}
showBootError(event.error);
});
const script = document.createElement("script");
script.src = `/admin-assets/app.js?v=${encodeURIComponent(assetNonce)}`;
script.defer = true;
script.onerror = () => showBootError(new Error("Failed to load /admin-assets/app.js"));
document.body.appendChild(script);

View File

@@ -195337,21 +195337,6 @@ const DQe =
{ path: "subscribe-template", element: Q.jsx(LQe, {}) },
],
},
{
path: "payment",
lazy: async () => ({
Component: (
await xp(
async () => {
const { default: e } = await Promise.resolve().then(() => kXt);
return { default: e };
},
void 0,
import.meta.url,
)
).default,
}),
},
{
path: "plugin",
lazy: async () => ({
@@ -195499,36 +195484,6 @@ const DQe =
).default,
}),
},
{
path: "coupon",
lazy: async () => ({
Component: (
await xp(
async () => {
const { default: e } = await Promise.resolve().then(() => z3t);
return { default: e };
},
void 0,
import.meta.url,
)
).default,
}),
},
{
path: "gift-card",
lazy: async () => ({
Component: (
await xp(
async () => {
const { default: e } = await Promise.resolve().then(() => d6t);
return { default: e };
},
void 0,
import.meta.url,
)
).default,
}),
},
],
},
{
@@ -195565,6 +195520,18 @@ const DQe =
).default,
}),
},
{
path: "realname",
lazy: async () => ({ Component: codexNativeRealnamePage }),
},
{
path: "online-devices",
lazy: async () => ({ Component: codexNativeOnlineDevicesPage }),
},
{
path: "ipv6-subscription",
lazy: async () => ({ Component: codexNativeIPv6Page }),
},
{
path: "traffic-reset-logs",
lazy: async () => ({
@@ -233443,17 +233410,11 @@ var Zst = {
href: "/config/system",
icon: Q.jsx(Kf, { size: 18 }),
},
{
title: "nav:pluginManagement",
label: "",
href: "/config/plugin",
icon: Q.jsx(Fat, { size: 18 }),
},
{
title: "nav:themeConfig",
label: "",
href: "/config/theme",
icon: Q.jsx(im, { size: 18 }),
icon: Q.jsx(Kf, { size: 18 }),
},
{
title: "nav:noticeManagement",
@@ -233461,12 +233422,6 @@ var Zst = {
href: "/config/notice",
icon: Q.jsx(pm, { size: 18 }),
},
{
title: "nav:paymentConfig",
label: "",
href: "/config/payment",
icon: Q.jsx(tm, { size: 18 }),
},
{
title: "nav:knowledgeManagement",
label: "",
@@ -233519,18 +233474,6 @@ var Zst = {
href: "/finance/order",
icon: Q.jsx(tm, { size: 18 }),
},
{
title: "nav:couponManagement",
label: "",
href: "/finance/coupon",
icon: Q.jsx(rm, { size: 18 }),
},
{
title: "nav:giftCardManagement",
label: "",
href: "/finance/gift-card",
icon: Q.jsx(lm, { size: 18 }),
},
],
},
{
@@ -233551,6 +233494,24 @@ var Zst = {
href: "/user/ticket",
icon: Q.jsx(xm, { size: 18 }),
},
{
title: "实名认证",
label: "",
href: "/user/realname",
icon: Q.jsx(xm, { size: 18 }),
},
{
title: "在线 IP 统计",
label: "",
href: "/user/online-devices",
icon: Q.jsx(xm, { size: 18 }),
},
{
title: "IPv6 子账号",
label: "",
href: "/user/ipv6-subscription",
icon: Q.jsx(xm, { size: 18 }),
},
],
},
];
@@ -264036,26 +263997,6 @@ function x$t({ className: e }) {
return Q.jsxs("div", {
className: Rf("grid gap-4 md:grid-cols-2 lg:grid-cols-4", e),
children: [
Q.jsx(y$t, {
title: n("dashboard:stats.todayIncome"),
value: DS(i.todayIncome),
icon: Q.jsx(Jst, { className: "h-4 w-4 text-emerald-500" }),
trend: {
value: i.dayIncomeGrowth,
label: n("dashboard:stats.vsYesterday"),
isPositive: i.dayIncomeGrowth > 0,
},
}),
Q.jsx(y$t, {
title: n("dashboard:stats.monthlyIncome"),
value: DS(i.currentMonthIncome),
icon: Q.jsx(rat, { className: "h-4 w-4 text-blue-500" }),
trend: {
value: i.monthIncomeGrowth,
label: n("dashboard:stats.vsLastMonth"),
isPositive: i.monthIncomeGrowth > 0,
},
}),
Q.jsx(y$t, {
title: n("dashboard:stats.pendingTickets"),
value: i.ticketPendingTotal,
@@ -264072,38 +264013,6 @@ function x$t({ className: e }) {
onClick: () => t("/user/ticket"),
highlight: i.ticketPendingTotal > 0,
}),
Q.jsx(y$t, {
title: n("dashboard:stats.pendingCommission"),
value: i.commissionPendingTotal,
icon: Q.jsx(oat, {
className: Rf(
"h-4 w-4",
i.commissionPendingTotal > 0 ? "text-blue-500" : "text-muted-foreground",
),
}),
description:
i.commissionPendingTotal > 0
? n("dashboard:stats.hasPendingCommission")
: n("dashboard:stats.noPendingCommission"),
onClick: () => {
const e = new URLSearchParams();
(e.set("commission_status", c$t.PENDING.toString()),
e.set("status", o$t.COMPLETED.toString()),
e.set("commission_balance", "gt:0"),
t(`/finance/order?${e.toString()}`));
},
highlight: i.commissionPendingTotal > 0,
}),
Q.jsx(y$t, {
title: n("dashboard:stats.monthlyNewUsers"),
value: i.currentMonthNewUsers,
icon: Q.jsx(dlt, { className: "h-4 w-4 text-blue-500" }),
trend: {
value: i.userGrowth,
label: n("dashboard:stats.vsLastMonth"),
isPositive: i.userGrowth > 0,
},
}),
Q.jsx(y$t, {
title: n("dashboard:stats.totalUsers"),
value: i.totalUsers,
@@ -269438,7 +269347,7 @@ const tGt = Object.freeze(
className: "space-y-6",
children: Q.jsxs("div", {
className: "grid gap-6",
children: [Q.jsx(x$t, {}), Q.jsx(t$t, {}), Q.jsx(dqt, {}), Q.jsx(eGt, {})],
children: [Q.jsx(x$t, {}), Q.jsx(dqt, {}), Q.jsx(eGt, {})],
}),
}),
}),
@@ -293836,23 +293745,27 @@ const I5t = {
Q.jsxs("span", {
className: "flex flex-col items-start gap-0.5 leading-tight",
children: [
Q.jsx("span", { className: "flex items-center gap-0.5", children: i ?? n }),
e.parent
? Q.jsxs("span", {
className: "flex items-center gap-1",
children: [
Q.jsx("span", { children: n }),
Q.jsx("span", {
className: "text-sm text-muted-foreground/30",
children: "\u2192",
}),
Q.jsx("span", { children: e.parent_id ?? e.parent?.id }),
],
})
: Q.jsx("span", {
className: "flex items-center gap-0.5",
children: n,
}),
i &&
Q.jsxs("span", {
className: "font-mono text-[10px] text-muted-foreground",
children: [t("columns.originalId"), ": ", n],
children: [t("columns.customId"), ": ", i],
}),
e.parent
? Q.jsxs(Q.Fragment, {
children: [
Q.jsx("span", {
className: "text-sm text-muted-foreground/30",
children: "→",
}),
Q.jsx("span", { children: e.parent?.code || e.parent?.id }),
],
})
: null,
],
}),
],
@@ -304571,6 +304484,496 @@ function l8t() {
})
);
}
function codexNativePageLayout(e, t, n) {
return Q.jsxs(Wot, {
children: [
Q.jsxs(Hot, {
children: [
Q.jsx(Vdt, {}),
Q.jsxs("div", {
className: "ml-auto flex items-center space-x-4",
children: [Q.jsx(Wdt, {}), Q.jsx(vut, {})],
}),
],
}),
Q.jsxs(zot, {
className: "flex flex-col",
fixedHeight: !0,
children: [
Q.jsx("div", {
className: "mb-2 flex items-center justify-between space-y-2",
children: Q.jsxs("div", {
children: [
Q.jsx("h2", { className: "text-2xl font-bold tracking-tight", children: e }),
Q.jsx("p", { className: "mt-2 text-muted-foreground", children: t }),
],
}),
}),
Q.jsx("div", {
className: "-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0",
children: Q.jsx("div", { className: "w-full", children: n }),
}),
],
}),
],
});
}
function codexNativeStatusBadge(e) {
const t = String(e || "-").toLowerCase(),
n = /approved|active|enabled|ready/.test(t)
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: /pending|eligible|unverified/.test(t)
? "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400"
: "bg-destructive/10 text-destructive";
return Q.jsx("span", {
className: Rf("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold", n),
children: e || "-",
});
}
function codexNativeRelationChip(e, t) {
return Q.jsxs("span", {
className:
"inline-flex items-center gap-1 rounded-full border bg-muted/50 px-2.5 py-1 font-mono text-xs",
children: [
e,
Q.jsx("span", { className: "text-muted-foreground/60", children: "\u2192" }),
t,
],
});
}
function codexNativeSearchToolbar({
keyword: e,
setKeyword: t,
onSearch: n,
onReset: i,
refetch: r,
actions: o,
}) {
return Q.jsxs("div", {
className: "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between",
children: [
Q.jsxs("div", {
className: "flex flex-1 flex-wrap items-center gap-2 sm:flex-nowrap",
children: [
Q.jsx(Q6e, {
placeholder: "搜索用户 ID / 邮箱",
value: e,
onChange: (e) => t(e.target.value),
onKeyDown: (e) => {
"Enter" === e.key && n();
},
className: "h-8 w-full min-w-[150px] sm:w-[180px] lg:w-[280px]",
}),
Q.jsx(Nm, { variant: "outline", className: "h-8 px-3", onClick: n, children: "搜索" }),
Q.jsx(Nm, { variant: "ghost", className: "h-8 px-3", onClick: i, children: "重置" }),
Q.jsx(Nm, { variant: "ghost", className: "h-8 px-3", onClick: () => r(), children: "刷新" }),
],
}),
o ? Q.jsx("div", { className: "flex flex-wrap items-center gap-2", children: o }) : null,
],
});
}
function codexNativeRealnameTable() {
const [e, t] = H.useState(""),
[n, i] = H.useState(""),
[r, o] = H.useState({}),
[s, a] = H.useState({}),
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
{
refetch: d,
data: u,
isLoading: h,
} = gC({
queryKey: ["codexNativeRealname", l, n],
queryFn: () =>
TL(`${RL()}/realname/records`, {
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
}),
}),
g = H.useMemo(
() => [
{
accessorKey: "id",
header: () => "用户 ID",
cell: ({ row: e }) =>
Q.jsx("div", { className: "font-mono text-sm font-medium", children: e.getValue("id") }),
},
{
accessorKey: "email",
header: () => "邮箱",
cell: ({ row: e }) =>
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
},
{
accessorKey: "real_name",
header: () => "姓名",
cell: ({ row: e }) => e.getValue("real_name") || "-",
},
{
accessorKey: "identity_no_masked",
header: () => "证件号",
cell: ({ row: e }) =>
Q.jsx("div", { className: "font-mono text-xs", children: e.getValue("identity_no_masked") || "-" }),
},
JKt.display({
id: "status",
header: () => "状态",
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
}),
JKt.display({
id: "actions",
header: () => "操作",
cell: ({ row: e }) =>
Q.jsxs("div", {
className: "flex flex-wrap items-center gap-2",
children: [
Q.jsx(Nm, {
size: "sm",
className: "h-8",
onClick: async () => {
await IL(`${RL()}/realname/review/${e.original.id}`, { status: "approved", reason: "" });
hN.success("已通过实名认证");
d();
},
children: "通过",
}),
Q.jsx(Nm, {
size: "sm",
variant: "outline",
className: "h-8",
onClick: async () => {
const t = window.prompt("请输入驳回原因", "") || "";
await IL(`${RL()}/realname/review/${e.original.id}`, {
status: "rejected",
reason: t,
});
hN.success("已驳回实名认证");
d();
},
children: "驳回",
}),
Q.jsx(Nm, {
size: "sm",
variant: "ghost",
className: "h-8",
onClick: async () => {
await IL(`${RL()}/realname/reset/${e.original.id}`, {});
hN.success("已重置实名记录");
d();
},
children: "重置",
}),
],
}),
}),
],
[d],
),
p = PKt({
data: u?.data?.data ?? [],
columns: g,
state: { columnVisibility: o, rowSelection: s, pagination: l },
getRowId: (e) => String(e.id),
rowCount: u?.data?.pagination?.total ?? 0,
manualPagination: !0,
enableRowSelection: !0,
onRowSelectionChange: a,
onColumnVisibilityChange: o,
onPaginationChange: c,
getCoreRowModel: LKt(),
getPaginationRowModel: OKt(),
});
return Q.jsxs("div", {
className: "space-y-4",
children: [
Q.jsx(codexNativeSearchToolbar, {
keyword: e,
setKeyword: t,
onSearch: () => {
c((e) => ({ ...e, pageIndex: 0 }));
i(e.trim());
},
onReset: () => {
t("");
i("");
c((e) => ({ ...e, pageIndex: 0 }));
},
refetch: d,
actions: Q.jsxs(Q.Fragment, {
children: [
Q.jsx(Nm, {
variant: "outline",
className: "h-8",
onClick: async () => {
await IL(`${RL()}/realname/sync-all`, {});
hN.success("已同步全部实名状态");
d();
},
children: "同步全部",
}),
Q.jsx(Nm, {
className: "h-8",
onClick: async () => {
await IL(`${RL()}/realname/approve-all`, {});
hN.success("已全部通过");
d();
},
children: "全部通过",
}),
],
}),
}),
Q.jsx(QKt, {
table: p,
isLoading: h,
showPagination: !0,
mobilePrimaryField: "email",
mobileGridFields: ["real_name", "status", "identity_no_masked"],
}),
],
});
}
function codexNativeRealnamePage() {
return codexNativePageLayout("实名认证", "审核实名记录并同步实名状态。", Q.jsx(codexNativeRealnameTable, {}));
}
function codexNativeOnlineDevicesTable() {
const [e, t] = H.useState(""),
[n, i] = H.useState(""),
[r, o] = H.useState({}),
[s, a] = H.useState({}),
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
{
refetch: d,
data: u,
isLoading: h,
} = gC({
queryKey: ["codexNativeOnlineDevices", l, n],
queryFn: () =>
TL(`${RL()}/user-online-devices/users`, {
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
}),
}),
g = H.useMemo(
() => [
{
accessorKey: "id",
header: () => "用户 ID",
cell: ({ row: e }) =>
Q.jsx("div", { className: "font-mono text-sm font-medium", children: e.getValue("id") }),
},
{
accessorKey: "email",
header: () => "邮箱",
cell: ({ row: e }) =>
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
},
{
accessorKey: "subscription_name",
header: () => "套餐",
cell: ({ row: e }) => e.getValue("subscription_name") || "-",
},
JKt.display({
id: "online_devices",
header: () => "在线 IP",
cell: ({ row: e }) => {
const t = e.original.online_devices || [];
return t.length
? Q.jsx("div", {
className: "flex flex-col gap-1 font-mono text-xs",
children: t.map((e, t) => Q.jsx("span", { children: e }, `${e}-${t}`)),
})
: "-";
},
}),
{
accessorKey: "online_count",
header: () => "数量",
cell: ({ row: e }) =>
Q.jsx("div", { className: "font-mono text-sm", children: e.getValue("online_count") || 0 }),
},
JKt.display({
id: "status",
header: () => "Status",
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
}),
{
accessorKey: "last_online_text",
header: () => "最后在线",
cell: ({ row: e }) =>
Q.jsx("div", { className: "text-nowrap text-sm text-muted-foreground", children: e.getValue("last_online_text") || "-" }),
},
],
[],
),
p = PKt({
data: u?.data?.list ?? [],
columns: g,
state: { columnVisibility: r, rowSelection: s, pagination: l },
getRowId: (e) => String(e.id),
rowCount: u?.data?.pagination?.total ?? 0,
manualPagination: !0,
enableRowSelection: !0,
onRowSelectionChange: a,
onColumnVisibilityChange: o,
onPaginationChange: c,
getCoreRowModel: LKt(),
getPaginationRowModel: OKt(),
});
return Q.jsxs("div", {
className: "space-y-4",
children: [
Q.jsx(codexNativeSearchToolbar, {
keyword: e,
setKeyword: t,
onSearch: () => {
c((e) => ({ ...e, pageIndex: 0 }));
i(e.trim());
},
onReset: () => {
t("");
i("");
c((e) => ({ ...e, pageIndex: 0 }));
},
refetch: d,
}),
Q.jsx(QKt, {
table: p,
isLoading: h,
showPagination: !0,
mobilePrimaryField: "email",
mobileGridFields: ["subscription_name", "online_count", "status", "last_online_text"],
}),
],
});
}
function codexNativeOnlineDevicesPage() {
return codexNativePageLayout("在线设备", "查看用户在线 IP、数量和最后在线时间。", Q.jsx(codexNativeOnlineDevicesTable, {}));
}
function codexNativeIPv6Table() {
const [e, t] = H.useState(""),
[n, i] = H.useState(""),
[r, o] = H.useState({}),
[s, a] = H.useState({}),
[l, c] = H.useState({ pageIndex: 0, pageSize: 20 }),
{
refetch: d,
data: u,
isLoading: h,
} = gC({
queryKey: ["codexNativeIPv6", l, n],
queryFn: () =>
TL(`${RL()}/user-add-ipv6-subscription/users`, {
params: { page: l.pageIndex + 1, per_page: l.pageSize, keyword: n },
}),
}),
g = H.useMemo(
() => [
JKt.display({
id: "relation",
header: () => "主从关系",
cell: ({ row: e }) =>
codexNativeRelationChip(e.original.id, e.original.shadow_user_id || "-"),
}),
{
accessorKey: "email",
header: () => "主账号",
cell: ({ row: e }) =>
Q.jsx("div", { className: "max-w-[240px] truncate", children: e.getValue("email") || "-" }),
},
{
accessorKey: "ipv6_email",
header: () => "IPv6 账号",
cell: ({ row: e }) =>
Q.jsx("div", { className: "font-mono text-xs", children: e.getValue("ipv6_email") || "-" }),
},
{
accessorKey: "plan_name",
header: () => "套餐",
cell: ({ row: e }) => e.getValue("plan_name") || "-",
},
JKt.display({
id: "status",
header: () => "状态",
cell: ({ row: e }) => codexNativeStatusBadge(e.original.status_label || e.original.status),
}),
JKt.display({
id: "actions",
header: () => "操作",
cell: ({ row: e }) =>
Q.jsxs("div", {
className: "flex flex-wrap items-center gap-2",
children: [
Q.jsx(Nm, {
size: "sm",
className: "h-8",
onClick: async () => {
await IL(`${RL()}/user-add-ipv6-subscription/enable/${e.original.id}`, {});
hN.success("已开通并同步 IPv6 子账号");
d();
},
children: "开通并同步",
}),
Q.jsx(Nm, {
size: "sm",
variant: "outline",
className: "h-8",
onClick: async () => {
await IL(`${RL()}/user-add-ipv6-subscription/sync-password/${e.original.id}`, {});
hN.success("已同步密码");
d();
},
children: "同步密码",
}),
],
}),
}),
],
[d],
),
p = PKt({
data: u?.data?.list ?? [],
columns: g,
state: { columnVisibility: r, rowSelection: s, pagination: l },
getRowId: (e) => String(e.id),
rowCount: u?.data?.pagination?.total ?? 0,
manualPagination: !0,
enableRowSelection: !0,
onRowSelectionChange: a,
onColumnVisibilityChange: o,
onPaginationChange: c,
getCoreRowModel: LKt(),
getPaginationRowModel: OKt(),
});
return Q.jsxs("div", {
className: "space-y-4",
children: [
Q.jsx(codexNativeSearchToolbar, {
keyword: e,
setKeyword: t,
onSearch: () => {
c((e) => ({ ...e, pageIndex: 0 }));
i(e.trim());
},
onReset: () => {
t("");
i("");
c((e) => ({ ...e, pageIndex: 0 }));
},
refetch: d,
}),
Q.jsx(QKt, {
table: p,
isLoading: h,
showPagination: !0,
mobilePrimaryField: "email",
mobileGridFields: ["ipv6_email", "plan_name", "status"],
}),
],
});
}
function codexNativeIPv6Page() {
return codexNativePageLayout("IPv6 子账号", "管理 IPv6 子账号开通、主从关系和密码同步。", Q.jsx(codexNativeIPv6Table, {}));
}
function c8t() {
const [e] = Hg(),
[t, n] = H.useState({}),

View File

@@ -3,10 +3,7 @@ export const navigationGroups = [
key: "config",
items: [
{ href: "/config/system", titleKey: "nav:systemConfig" },
{ href: "/config/plugin", titleKey: "nav:pluginManagement" },
{ href: "/config/theme", titleKey: "nav:themeConfig" },
{ href: "/config/notice", titleKey: "nav:noticeManagement" },
{ href: "/config/payment", titleKey: "nav:paymentConfig" },
{ href: "/config/knowledge", titleKey: "nav:knowledgeManagement" },
],
},
@@ -16,10 +13,7 @@ export const navigationGroups = [
},
{
key: "finance",
items: [
{ href: "/finance/plan", titleKey: "nav:planManagement" },
{ href: "/finance/gift-card", titleKey: "nav:giftCardManagement" },
],
items: [{ href: "/finance/plan", titleKey: "nav:planManagement" }],
},
{
key: "user",
@@ -37,6 +31,7 @@ export const navigationGroups = [
{ href: "/config/system/invite", titleKey: "nav:inviteConfig" },
{ href: "/config/system/server", titleKey: "nav:serverConfig" },
{ href: "/config/system/email", titleKey: "nav:emailConfig" },
{ href: "/config/theme", titleKey: "nav:themeConfig" },
{ href: "/config/system/telegram", titleKey: "nav:telegramConfig" },
{ href: "/config/system/app", titleKey: "nav:appConfig" },
{ href: "/config/system/subscribe-template", titleKey: "nav:subscribeTemplateConfig" },

View File

@@ -10,7 +10,6 @@ import SystemEmailPage from "../pages/config/system/SystemEmailPage.js";
import SystemTelegramPage from "../pages/config/system/SystemTelegramPage.js";
import SystemAppPage from "../pages/config/system/SystemAppPage.js";
import SubscribeTemplatePage from "../pages/config/system/SubscribeTemplatePage.js";
import PaymentConfigPage from "../pages/config/PaymentConfigPage.js";
import PluginManagementPage from "../pages/config/PluginManagementPage.js";
import ThemeConfigPage from "../pages/config/ThemeConfigPage.js";
import NoticeManagementPage from "../pages/config/NoticeManagementPage.js";
@@ -20,8 +19,6 @@ import ServerGroupPage from "../pages/server/ServerGroupPage.js";
import ServerRoutePage from "../pages/server/ServerRoutePage.js";
import FinancePlanPage from "../pages/finance/FinancePlanPage.js";
import FinanceOrderPage from "../pages/finance/FinanceOrderPage.js";
import FinanceCouponPage from "../pages/finance/FinanceCouponPage.js";
import FinanceGiftCardPage from "../pages/finance/FinanceGiftCardPage.js";
import UserManagePage from "../pages/user/UserManagePage.js";
import UserTicketPage from "../pages/user/UserTicketPage.js";
import TrafficResetLogsPage from "../pages/user/TrafficResetLogsPage.js";
@@ -42,7 +39,6 @@ export const reverseRoutes = [
{ path: "/config/system/telegram", page: SystemTelegramPage },
{ path: "/config/system/app", page: SystemAppPage },
{ path: "/config/system/subscribe-template", page: SubscribeTemplatePage },
{ path: "/config/payment", page: PaymentConfigPage },
{ path: "/config/plugin", page: PluginManagementPage },
{ path: "/config/theme", page: ThemeConfigPage },
{ path: "/config/notice", page: NoticeManagementPage },
@@ -52,8 +48,6 @@ export const reverseRoutes = [
{ path: "/server/route", page: ServerRoutePage },
{ path: "/finance/plan", page: FinancePlanPage },
{ path: "/finance/order", page: FinanceOrderPage },
{ path: "/finance/coupon", page: FinanceCouponPage },
{ path: "/finance/gift-card", page: FinanceGiftCardPage },
{ path: "/user/manage", page: UserManagePage },
{ path: "/user/ticket", page: UserTicketPage },
{ path: "/user/traffic-reset-logs", page: TrafficResetLogsPage },

View File

@@ -1,9 +1,10 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
import ThemeConfigPageView from "./ThemeConfigPage.jsx";
export default makeSkeletonPage({
export default makeRecoveredPage({
title: "Theme Config",
routePath: "/config/theme",
moduleId: "WQt",
featureKey: "config.theme",
notes: ["Bundle contains theme activation and theme config editing flows."],
component: ThemeConfigPageView,
});

View File

@@ -0,0 +1,226 @@
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
import {
compactText,
requestJson,
} from "../../runtime/client.js";
// Wot: Wrapper structure for the config page
function Wot({ children }) {
return <div className="recovery-live-page" style={{ padding: '2rem' }}>{children}</div>;
}
// Hot: Header component for sections or the page
function Hot({ title, description }) {
return (
<header className="recovery-live-hero" style={{ marginBottom: '2.5rem', borderBottom: '1px solid var(--border-soft)', pb: '1.5rem' }}>
<div style={{ maxWidth: '800px' }}>
<p className="eyebrow" style={{ color: 'var(--brand-color)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Theme Customization
</p>
<h2 style={{ fontSize: '2.25rem', marginBottom: '0.75rem' }}>{title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: '1.6' }}>{description}</p>
</div>
</header>
);
}
// zot: Setting Item (Zone) component for form fields
function zot({ label, children, span = 1 }) {
return (
<div className={`recovery-table-card ${span === 2 ? 'span-2' : ''}`} style={{ padding: '1.25rem', background: 'var(--card-bg)', borderRadius: '12px', border: '1px solid var(--card-border)' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', fontWeight: '500', color: 'var(--text-main)' }}>{label}</label>
<div className="form-control-wrapper">
{children}
</div>
</div>
);
}
// QKt: The main Nebula configuration component
function QKt() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
nebula_theme_color: 'aurora',
nebula_hero_slogan: '',
nebula_welcome_target: '',
nebula_register_title: '',
nebula_background_url: '',
nebula_metrics_base_url: '',
nebula_default_theme_mode: 'system',
nebula_light_logo_url: '',
nebula_dark_logo_url: '',
nebula_custom_html: '',
nebula_static_cdn_url: '',
});
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
(async () => {
try {
const payload = await requestJson("/config/fetch?key=nebula");
if (payload?.nebula) {
setForm(prev => ({ ...prev, ...payload.nebula }));
}
} catch (err) {
setError("Failed to load configuration: " + err.message);
} finally {
setLoading(false);
}
})();
}, []);
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
setSuccess("");
setError("");
try {
await requestJson("/config/save", {
method: "POST",
body: form,
});
setSuccess("Nebula configuration updated successfully.");
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
setError("Failed to save configuration: " + err.message);
} finally {
setSaving(false);
}
};
if (loading) return <Wot><div className="empty-cell">Initializing Nebula settings...</div></Wot>;
return (
<Wot>
<Hot
title="Nebula Theme settings"
description="Optimize your user dashboard with specific Nebula theme settings and branding options."
/>
{error && <div className="toast toast-error" style={{ marginBottom: '1.5rem' }}>{error}</div>}
{success && <div className="toast toast-success" style={{ marginBottom: '1.5rem' }}>{success}</div>}
<form onSubmit={handleSave} className="modal-grid" style={{ maxWidth: '1200px' }}>
<zot label="Primary Accent Color" span={2}>
<select
value={form.nebula_theme_color}
onChange={e => setForm({...form, nebula_theme_color: e.target.value})}
className="recovery-input"
style={{ width: '100%', height: '42px' }}
>
<option value="aurora">极光蓝 (Aurora Blue)</option>
<option value="sunset">日落橙 (Sunset Orange)</option>
<option value="ember">余烬红 (Ember Red)</option>
<option value="violet">星云紫 (Violet Purple)</option>
</select>
</zot>
<zot label="Hero Slogan">
<input
className="recovery-input"
value={form.nebula_hero_slogan}
onChange={e => setForm({...form, nebula_hero_slogan: e.target.value})}
placeholder="Main visual headline"
/>
</zot>
<zot label="Welcome Target">
<input
className="recovery-input"
value={form.nebula_welcome_target}
onChange={e => setForm({...form, nebula_welcome_target: e.target.value})}
placeholder="Name displayed after WELCOME TO"
/>
</zot>
<zot label="Register Title">
<input
className="recovery-input"
value={form.nebula_register_title}
onChange={e => setForm({...form, nebula_register_title: e.target.value})}
placeholder="Title on registration panel"
/>
</zot>
<zot label="Default Appearance">
<select
value={form.nebula_default_theme_mode}
onChange={e => setForm({...form, nebula_default_theme_mode: e.target.value})}
className="recovery-input"
style={{ width: '100%', height: '42px' }}
>
<option value="system">Adaptive (System)</option>
<option value="dark">Dark Theme</option>
<option value="light">Light Theme</option>
</select>
</zot>
<zot label="Background Image URL">
<input
className="recovery-input"
value={form.nebula_background_url}
onChange={e => setForm({...form, nebula_background_url: e.target.value})}
placeholder="Direct link to background image"
/>
</zot>
<zot label="Metrics API Domain">
<input
className="recovery-input"
value={form.nebula_metrics_base_url}
onChange={e => setForm({...form, nebula_metrics_base_url: e.target.value})}
placeholder="https://stats.example.com"
/>
</zot>
<zot label="Light Mode Logo">
<input
className="recovery-input"
value={form.nebula_light_logo_url}
onChange={e => setForm({...form, nebula_light_logo_url: e.target.value})}
placeholder="Logo for light mode"
/>
</zot>
<zot label="Dark Mode Logo">
<input
className="recovery-input"
value={form.nebula_dark_logo_url}
onChange={e => setForm({...form, nebula_dark_logo_url: e.target.value})}
placeholder="Logo for dark mode"
/>
</zot>
<zot label="Static CDN Assets" span={2}>
<input
className="recovery-input"
value={form.nebula_static_cdn_url}
onChange={e => setForm({...form, nebula_static_cdn_url: e.target.value})}
placeholder="e.g. https://cdn.example.com/nebula (no trailing slash)"
/>
</zot>
<zot label="Custom Scripts / CSS" span={2}>
<textarea
className="recovery-input"
rows={6}
value={form.nebula_custom_html}
onChange={e => setForm({...form, nebula_custom_html: e.target.value})}
placeholder="Add analytics codes or custom styles here"
style={{ fontFamily: 'monospace' }}
/>
</zot>
<div className="span-2" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'flex-end', borderTop: '1px solid var(--border-soft)', paddingTop: '1.5rem' }}>
<button type="submit" className="primary-btn" disabled={saving}>
{saving ? "Persisting..." : "Save Configuration"}
</button>
</div>
</form>
</Wot>
);
}
export default QKt;