修复丢失的前端文件
Some checks failed
build / build (api, amd64, linux) (push) Failing after -52s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -52s

This commit is contained in:
CN-JS-HuiBai
2026-04-18 22:03:26 +08:00
parent 8cca428d89
commit 609ab002b3
60 changed files with 338638 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
# Admin Reverse Workspace
This directory is the in-repo workspace for reversing `frontend/admin/assets/index-CO3BwsT2.js`
and related compiled admin assets into maintainable source-level structure.
## Current Status
- `output/`
- Pretty-printed JS/CSS/locales generated from the compiled bundle.
- `extracted/router-manifest.js`
- First-pass route tree extracted from the React Router config inside the bundle.
- `extracted/api-manifest.js`
- First-pass admin API endpoint inventory extracted from the Axios wrapper section.
- `extracted/runtime-manifest.js`
- First-pass shared runtime inventory for i18n, request client, auth, and error handling.
- `extracted/navigation-manifest.js`
- First-pass sidebar/navigation href and i18n-key extraction.
- `scripts/inspect-bundle.cjs`
- AST scan used to produce a structural report.
- `scripts/beautify-bundle.cjs`
- Pretty-printer for the main bundle and locale files.
## What Was Still Incomplete Before Resuming
The interrupted run had only completed:
- Node/npm tooling bootstrap
- pretty-print output generation
- route manifest extraction
It had not yet completed:
- API manifest extraction
- reverse-workspace documentation
- source skeleton mapping from route/component ids back into `frontend/admin/src`
Those missing pieces are now partially completed by this workspace.
## Next Recommended Steps
1. Extract the sidebar/menu metadata from the bundle so route labels and navigation groups are preserved.
2. Map each lazy route factory symbol to a provisional source file in a reconstructed `src-reverse/`.
3. Extract shared request/auth/i18n/runtime modules from the bundle into readable modules first.
4. Rebuild page-by-page, starting from:
- `user/manage`
- `server/manage`
- `finance/plan`
- `user/ticket`
5. Only replace the current hand-written `frontend/admin/src` after the reconstructed source tree can render equivalently.
## Running The Tooling
Because the current shell session may not have Node in PATH, run with an explicit prefix if needed:
```powershell
$env:Path='C:\Program Files\nodejs;' + $env:Path
& 'C:\Program Files\nodejs\npm.cmd' run inspect
& 'C:\Program Files\nodejs\npm.cmd' run beautify
```

View File

@@ -0,0 +1,148 @@
const apiManifest = {
runtime: {
baseUrl: "window.settings.base_url + /api/v2",
securePathPrefix: "window.settings.secure_path",
publicEndpoints: [
"/passport/auth/login",
"/passport/auth/token2Login",
"/passport/auth/register",
"/guest/comm/config",
"/passport/comm/sendEmailVerify",
"/passport/auth/forget",
],
notes: [
"All authenticated admin endpoints are prefixed with the secure path.",
"Authorization header is injected automatically except for public endpoints.",
"Content-Language header comes from localStorage.i18nextLng.",
],
},
modules: {
stat: {
getOrder: "GET {secure}/stat/getOrder",
getStats: "GET {secure}/stat/getStats",
getTrafficRank: "GET {secure}/stat/getTrafficRank",
},
theme: {
getThemes: "GET {secure}/theme/getThemes",
getThemeConfig: "POST {secure}/theme/getThemeConfig",
saveThemeConfig: "POST {secure}/theme/saveThemeConfig",
upload: "POST {secure}/theme/upload",
remove: "POST {secure}/theme/delete",
},
serverManage: {
getNodes: "GET {secure}/server/manage/getNodes",
save: "POST {secure}/server/manage/save",
drop: "POST {secure}/server/manage/drop",
batchDelete: "POST {secure}/server/manage/batchDelete",
copy: "POST {secure}/server/manage/copy",
update: "POST {secure}/server/manage/update",
sort: "POST {secure}/server/manage/sort",
resetTraffic: "POST {secure}/server/manage/resetTraffic",
batchResetTraffic: "POST {secure}/server/manage/batchResetTraffic",
},
serverGroup: {
fetch: "GET {secure}/server/group/fetch",
save: "POST {secure}/server/group/save",
drop: "POST {secure}/server/group/drop",
},
serverRoute: {
fetch: "GET {secure}/server/route/fetch",
save: "POST {secure}/server/route/save",
drop: "POST {secure}/server/route/drop",
},
payment: {
fetch: "GET {secure}/payment/fetch",
getPaymentMethods: "GET {secure}/payment/getPaymentMethods",
getPaymentForm: "POST {secure}/payment/getPaymentForm",
save: "POST {secure}/payment/save",
drop: "POST {secure}/payment/drop",
show: "POST {secure}/payment/show",
sort: "POST {secure}/payment/sort",
},
notice: {
fetch: "GET {secure}/notice/fetch",
save: "POST {secure}/notice/save",
drop: "POST {secure}/notice/drop",
show: "POST {secure}/notice/show",
sort: "POST {secure}/notice/sort",
},
knowledge: {
fetch: "GET {secure}/knowledge/fetch",
fetchById: "GET {secure}/knowledge/fetch?id={id}",
save: "POST {secure}/knowledge/save",
drop: "POST {secure}/knowledge/drop",
show: "POST {secure}/knowledge/show",
sort: "POST {secure}/knowledge/sort",
},
plan: {
fetch: "GET {secure}/plan/fetch",
save: "POST {secure}/plan/save",
update: "POST {secure}/plan/update",
drop: "POST {secure}/plan/drop",
sort: "POST {secure}/plan/sort",
},
order: {
fetch: "POST {secure}/order/fetch",
detail: "POST {secure}/order/detail",
paid: "POST {secure}/order/paid",
cancel: "POST {secure}/order/cancel",
update: "POST {secure}/order/update",
assign: "POST {secure}/order/assign",
},
giftCard: {
templates: "POST {secure}/gift-card/templates",
createTemplate: "POST {secure}/gift-card/create-template",
updateTemplate: "POST {secure}/gift-card/update-template",
deleteTemplate: "POST {secure}/gift-card/delete-template",
codes: "POST {secure}/gift-card/codes",
generateCodes: "POST {secure}/gift-card/generate-codes",
toggleCode: "POST {secure}/gift-card/toggle-code",
usages: "POST {secure}/gift-card/usages",
statistics: "POST {secure}/gift-card/statistics",
},
coupon: {
fetch: "POST {secure}/coupon/fetch",
generate: "POST {secure}/coupon/generate",
drop: "POST {secure}/coupon/drop",
update: "POST {secure}/coupon/update",
},
user: {
fetch: "POST {secure}/user/fetch",
update: "POST {secure}/user/update",
resetSecret: "POST {secure}/user/resetSecret",
generate: "POST {secure}/user/generate",
getStatUser: "POST {secure}/stat/getStatUser",
destroy: "POST {secure}/user/destroy",
sendMail: "POST {secure}/user/sendMail",
dumpCsv: "POST {secure}/user/dumpCSV",
ban: "POST {secure}/user/ban",
},
trafficReset: {
logs: "GET {secure}/traffic-reset/logs",
resetUser: "POST {secure}/traffic-reset/reset-user",
userHistory: "GET {secure}/traffic-reset/user/{id}/history",
},
ticket: {
fetch: "POST {secure}/ticket/fetch",
fetchById: "GET {secure}/ticket/fetch?id={id}",
reply: "POST {secure}/ticket/reply",
close: "POST {secure}/ticket/close",
},
config: {
fetch: "GET {secure}/config/fetch?key={key}",
save: "POST {secure}/config/save",
getEmailTemplate: "GET {secure}/config/getEmailTemplate",
testSendMail: "POST {secure}/config/testSendMail",
setTelegramWebhook: "POST {secure}/config/setTelegramWebhook",
systemStatus: "GET {secure}/system/getSystemStatus",
queueStats: "GET {secure}/system/getQueueStats",
queueWorkload: "GET {secure}/system/getQueueWorkload",
queueMasters: "GET {secure}/system/getQueueMasters",
horizonFailedJobs: "GET {secure}/system/getHorizonFailedJobs",
},
},
};
module.exports = {
apiManifest,
};

View File

@@ -0,0 +1,32 @@
const navigationManifest = {
notes: [
"This is a first-pass extraction from visible href/title pairs found in the compiled bundle.",
"Titles are i18n keys from the nav namespace, not final rendered text.",
],
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" },
{ href: "/server/manage", titleKey: "nav:serverManagement" },
{ href: "/finance/plan", titleKey: "nav:planManagement" },
{ href: "/finance/gift-card", titleKey: "nav:giftCardManagement" },
{ href: "/user/manage", titleKey: "nav:userManagement" },
{ href: "/user/ticket", titleKey: "nav:ticketManagement" },
{ href: "/config/system", titleKey: "nav:siteConfig" },
{ href: "/config/system/safe", titleKey: "nav:safeConfig" },
{ href: "/config/system/subscribe", titleKey: "nav:subscribeConfig" },
{ href: "/config/system/invite", titleKey: "nav:inviteConfig" },
{ href: "/config/system/server", titleKey: "nav:serverConfig" },
{ href: "/config/system/email", titleKey: "nav:emailConfig" },
{ href: "/config/system/telegram", titleKey: "nav:telegramConfig" },
{ href: "/config/system/app", titleKey: "nav:appConfig" },
{ href: "/config/system/subscribe-template", titleKey: "nav:subscribeTemplateConfig" },
],
};
module.exports = {
navigationManifest,
};

View File

@@ -0,0 +1,77 @@
const adminRouteManifest = [
{
path: "/sign-in",
mode: "lazy",
note: "login page",
},
{
path: "/",
layout: "authenticated",
children: [
{
index: true,
mode: "lazy",
note: "dashboard overview",
},
{
path: "config",
children: [
{
path: "system",
mode: "lazy",
children: [
{ index: true, mode: "lazy", note: "system overview" },
{ path: "safe", mode: "lazy" },
{ path: "subscribe", mode: "lazy" },
{ path: "invite", mode: "lazy" },
{ path: "frontend", mode: "lazy" },
{ path: "server", mode: "lazy" },
{ path: "email", mode: "lazy" },
{ path: "telegram", mode: "lazy" },
{ path: "APP", mode: "lazy" },
{ path: "subscribe-template", mode: "inline" },
],
},
{ path: "payment", mode: "lazy" },
{ path: "plugin", mode: "lazy" },
{ path: "theme", mode: "lazy" },
{ path: "notice", mode: "lazy" },
{ path: "knowledge", mode: "lazy" },
],
},
{
path: "server",
children: [
{ path: "manage", mode: "lazy" },
{ path: "group", mode: "lazy" },
{ path: "route", mode: "lazy" },
],
},
{
path: "finance",
children: [
{ path: "plan", mode: "lazy" },
{ path: "order", mode: "lazy" },
{ path: "coupon", mode: "lazy" },
{ path: "gift-card", mode: "lazy" },
],
},
{
path: "user",
children: [
{ path: "manage", mode: "lazy" },
{ path: "ticket", mode: "lazy" },
{ path: "traffic-reset-logs", mode: "lazy" },
],
},
],
},
{ path: "/500", mode: "static" },
{ path: "/404", mode: "static" },
{ path: "/503", mode: "static" },
{ path: "*", mode: "static" },
];
module.exports = {
adminRouteManifest,
};

View File

@@ -0,0 +1,42 @@
const runtimeManifest = {
i18n: {
resources: ["en-US", "zh-CN", "ko-KR", "ru-RU"],
fallbackLng: "zh-CN",
detection: {
order: ["querystring", "localStorage", "navigator"],
lookupQuerystring: "lang",
lookupLocalStorage: "i18nextLng",
caches: ["localStorage"],
},
},
request: {
client: "axios.create",
baseURL: "window.settings.base_url + /api/v2",
timeoutMs: 30000,
contentType: "application/json",
authTokenSource: "Am() token store",
securePathSource: "window.settings.secure_path",
publicEndpoints: [
"/passport/auth/login",
"/passport/auth/token2Login",
"/passport/auth/register",
"/guest/comm/config",
"/passport/comm/sendEmailVerify",
"/passport/auth/forget",
],
requestInterceptors: [
"Attach Authorization for non-public endpoints",
"Attach Content-Language from localStorage.i18nextLng or zh-CN",
],
responseInterceptors: [
"Unwrap response.data",
"On 401 or 403 trigger logout flow",
"Map common HTTP errors to i18n messages",
"Show toast via hN.error on failed responses",
],
},
};
module.exports = {
runtimeManifest,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

232
frontend/admin/reverse/package-lock.json generated Normal file
View File

@@ -0,0 +1,232 @@
{
"name": "admin-reverse-tooling",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "admin-reverse-tooling",
"version": "0.0.0",
"dependencies": {
"@babel/generator": "^7.27.5",
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"prettier": "^3.6.2"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "admin-reverse-tooling",
"private": true,
"version": "0.0.0",
"type": "commonjs",
"scripts": {
"beautify": "node scripts/beautify-bundle.cjs",
"inspect": "node scripts/inspect-bundle.cjs"
},
"dependencies": {
"@babel/generator": "^7.27.5",
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"prettier": "^3.6.2"
}
}

View File

@@ -0,0 +1,55 @@
const fs = require("fs");
const path = require("path");
const prettier = require("prettier");
const projectRoot = path.resolve(__dirname, "..", "..");
const outputRoot = path.resolve(__dirname, "..", "output");
const files = [
{
input: path.join(projectRoot, "assets", "index-CO3BwsT2.js"),
output: path.join(outputRoot, "index-CO3BwsT2.pretty.js"),
parser: "babel",
},
{
input: path.join(projectRoot, "assets", "index-DTPKq_WI.css"),
output: path.join(outputRoot, "index-DTPKq_WI.pretty.css"),
parser: "css",
},
{
input: path.join(projectRoot, "locales", "en-US.js"),
output: path.join(outputRoot, "locales", "en-US.pretty.js"),
parser: "babel",
},
{
input: path.join(projectRoot, "locales", "zh-CN.js"),
output: path.join(outputRoot, "locales", "zh-CN.pretty.js"),
parser: "babel",
},
{
input: path.join(projectRoot, "locales", "ru-RU.js"),
output: path.join(outputRoot, "locales", "ru-RU.pretty.js"),
parser: "babel",
},
];
async function main() {
for (const file of files) {
const source = fs.readFileSync(file.input, "utf8");
const formatted = await prettier.format(source, {
parser: file.parser,
printWidth: 100,
trailingComma: "all",
singleQuote: false,
});
fs.mkdirSync(path.dirname(file.output), { recursive: true });
fs.writeFileSync(file.output, formatted, "utf8");
console.log(`formatted: ${path.relative(outputRoot, file.output)}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,95 @@
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const projectRoot = path.resolve(__dirname, "..", "..");
const bundlePath = path.join(projectRoot, "assets", "index-CO3BwsT2.js");
const outputRoot = path.resolve(__dirname, "..", "output");
const source = fs.readFileSync(bundlePath, "utf8");
const ast = parser.parse(source, {
sourceType: "module",
plugins: [
"jsx",
"classProperties",
"classPrivateProperties",
"classPrivateMethods",
"optionalChaining",
"nullishCoalescingOperator",
"dynamicImport",
"topLevelAwait",
],
errorRecovery: true,
});
const importedChunks = new Set();
const stringLiterals = new Map();
const apiCandidates = new Set();
const routeCandidates = new Set();
const componentCandidates = new Set();
function bump(map, value) {
map.set(value, (map.get(value) || 0) + 1);
}
traverse(ast, {
ImportExpression(path) {
const sourceNode = path.node.source;
if (sourceNode.type === "StringLiteral") {
importedChunks.add(sourceNode.value);
}
},
StringLiteral(path) {
const value = path.node.value;
if (!value || value.length < 2) {
return;
}
if (value.startsWith("/api/")) {
apiCandidates.add(value);
}
if (value.startsWith("/") && /[a-z]/i.test(value)) {
if (!value.includes(".")) {
routeCandidates.add(value);
}
}
if (/^[A-Z][A-Za-z0-9]+$/.test(value) && value.length < 40) {
componentCandidates.add(value);
}
bump(stringLiterals, value);
},
TemplateElement(path) {
const value = path.node.value.raw;
if (value.includes("/api/")) {
apiCandidates.add(value);
}
},
});
const topStrings = [...stringLiterals.entries()]
.filter(([value]) => value.length <= 120)
.sort((a, b) => b[1] - a[1])
.slice(0, 300)
.map(([value, count]) => ({ value, count }));
const report = {
bundlePath,
importedChunks: [...importedChunks].sort(),
apiCandidates: [...apiCandidates].sort(),
routeCandidates: [...routeCandidates].sort(),
componentCandidates: [...componentCandidates].sort(),
topStrings,
};
fs.mkdirSync(outputRoot, { recursive: true });
fs.writeFileSync(
path.join(outputRoot, "bundle-report.json"),
JSON.stringify(report, null, 2),
"utf8",
);
console.log(`report: ${path.join(outputRoot, "bundle-report.json")}`);

View File

@@ -0,0 +1,25 @@
This directory is the reconstructed source skeleton for the compiled admin dist.
Goals:
- preserve route/page/module boundaries discovered from the compiled bundle
- provide a safe place to rebuild maintainable source without disturbing `src/`
- gradually replace placeholders with reconstructed implementations
Current state:
- runtime module skeletons are in `runtime/`
- route and navigation registries are in `config/`
- page placeholders mirror the compiled route tree in `pages/`
- recovered page descriptors with real API/state/table/form logic now exist for:
- `pages/DashboardOverviewPage.js`
- `pages/finance/FinancePlanPage.js`
- the following route groups already have placeholder source files:
- auth
- dashboard
- config
- server
- finance
- user
- error pages
This tree is intentionally incomplete at the implementation level, but complete at the
module-boundary level for the routes identified so far.

View File

@@ -0,0 +1,59 @@
export const apiModules = {
stat: ["getOrder", "getStats", "getTrafficRank"],
theme: ["getThemes", "getThemeConfig", "saveThemeConfig", "upload", "remove"],
serverManage: [
"getNodes",
"save",
"drop",
"batchDelete",
"copy",
"update",
"sort",
"resetTraffic",
"batchResetTraffic",
],
serverGroup: ["fetch", "save", "drop"],
serverRoute: ["fetch", "save", "drop"],
payment: ["fetch", "getPaymentMethods", "getPaymentForm", "save", "drop", "show", "sort"],
notice: ["fetch", "save", "drop", "show", "sort"],
knowledge: ["fetch", "fetchById", "save", "drop", "show", "sort"],
plan: ["fetch", "save", "update", "drop", "sort"],
order: ["fetch", "detail", "paid", "cancel", "update", "assign"],
giftCard: [
"templates",
"createTemplate",
"updateTemplate",
"deleteTemplate",
"codes",
"generateCodes",
"toggleCode",
"usages",
"statistics",
],
coupon: ["fetch", "generate", "drop", "update"],
user: [
"fetch",
"update",
"resetSecret",
"generate",
"getStatUser",
"destroy",
"sendMail",
"dumpCsv",
"ban",
],
trafficReset: ["logs", "resetUser", "userHistory"],
ticket: ["fetch", "fetchById", "reply", "close"],
config: [
"fetch",
"save",
"getEmailTemplate",
"testSendMail",
"setTelegramWebhook",
"systemStatus",
"queueStats",
"queueWorkload",
"queueMasters",
"horizonFailedJobs",
],
};

View File

@@ -0,0 +1,40 @@
export const navigationGroups = [
{
key: "config",
items: [
{ href: "/config/system", titleKey: "nav:systemConfig" },
{ href: "/config/notice", titleKey: "nav:noticeManagement" },
{ href: "/config/knowledge", titleKey: "nav:knowledgeManagement" },
],
},
{
key: "server",
items: [{ href: "/server/manage", titleKey: "nav:serverManagement" }],
},
{
key: "finance",
items: [{ href: "/finance/plan", titleKey: "nav:planManagement" }],
},
{
key: "user",
items: [
{ href: "/user/manage", titleKey: "nav:userManagement" },
{ href: "/user/ticket", titleKey: "nav:ticketManagement" },
],
},
{
key: "config-system",
items: [
{ href: "/config/system", titleKey: "nav:siteConfig" },
{ href: "/config/system/safe", titleKey: "nav:safeConfig" },
{ href: "/config/system/subscribe", titleKey: "nav:subscribeConfig" },
{ 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

@@ -0,0 +1,58 @@
import SignInPage from "../pages/SignInPage.js";
import DashboardOverviewPage from "../pages/DashboardOverviewPage.js";
import SystemOverviewPage from "../pages/config/system/SystemOverviewPage.js";
import SystemSafePage from "../pages/config/system/SystemSafePage.js";
import SystemSubscribePage from "../pages/config/system/SystemSubscribePage.js";
import SystemInvitePage from "../pages/config/system/SystemInvitePage.js";
import SystemFrontendPage from "../pages/config/system/SystemFrontendPage.js";
import SystemServerPage from "../pages/config/system/SystemServerPage.js";
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 PluginManagementPage from "../pages/config/PluginManagementPage.js";
import ThemeConfigPage from "../pages/config/ThemeConfigPage.js";
import NoticeManagementPage from "../pages/config/NoticeManagementPage.js";
import KnowledgeManagementPage from "../pages/config/KnowledgeManagementPage.js";
import ServerManagePage from "../pages/server/ServerManagePage.js";
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 UserManagePage from "../pages/user/UserManagePage.js";
import UserTicketPage from "../pages/user/UserTicketPage.js";
import TrafficResetLogsPage from "../pages/user/TrafficResetLogsPage.js";
import Error404Page from "../pages/errors/Error404Page.js";
import Error500Page from "../pages/errors/Error500Page.js";
import Error503Page from "../pages/errors/Error503Page.js";
export const reverseRoutes = [
{ path: "/sign-in", page: SignInPage },
{ path: "/", page: DashboardOverviewPage, index: true },
{ path: "/config/system", page: SystemOverviewPage },
{ path: "/config/system/safe", page: SystemSafePage },
{ path: "/config/system/subscribe", page: SystemSubscribePage },
{ path: "/config/system/invite", page: SystemInvitePage },
{ path: "/config/system/frontend", page: SystemFrontendPage },
{ path: "/config/system/server", page: SystemServerPage },
{ path: "/config/system/email", page: SystemEmailPage },
{ path: "/config/system/telegram", page: SystemTelegramPage },
{ path: "/config/system/app", page: SystemAppPage },
{ path: "/config/system/subscribe-template", page: SubscribeTemplatePage },
{ path: "/config/plugin", page: PluginManagementPage },
{ path: "/config/theme", page: ThemeConfigPage },
{ path: "/config/notice", page: NoticeManagementPage },
{ path: "/config/knowledge", page: KnowledgeManagementPage },
{ path: "/server/manage", page: ServerManagePage },
{ path: "/server/group", page: ServerGroupPage },
{ path: "/server/route", page: ServerRoutePage },
{ path: "/finance/plan", page: FinancePlanPage },
{ path: "/finance/order", page: FinanceOrderPage },
{ path: "/user/manage", page: UserManagePage },
{ path: "/user/ticket", page: UserTicketPage },
{ path: "/user/traffic-reset-logs", page: TrafficResetLogsPage },
{ path: "/404", page: Error404Page },
{ path: "/500", page: Error500Page },
{ path: "/503", page: Error503Page },
{ path: "*", page: Error404Page },
];

View File

@@ -0,0 +1,4 @@
export { reverseRoutes } from "./config/routes.js";
export { navigationGroups } from "./config/navigation.js";
export { apiModules } from "./config/apiModules.js";
export { getAppMeta } from "./runtime/settings.js";

View File

@@ -0,0 +1,393 @@
import { makeRecoveredPage } from "../runtime/makeRecoveredPage.js";
export default makeRecoveredPage({
title: "Dashboard Overview",
routePath: "/",
moduleId: "tGt",
featureKey: "dashboard.overview",
translationNamespaces: ["dashboard", "common", "order"],
summary:
"The dashboard route is composed of four live sections: summary statistic cards, order/commission trend analytics, traffic rankings, and queue health details. All sections are hydrated from polling queries rather than static config.",
api: [
{
name: "stat.getStats",
method: "GET",
path: "{secure}/stat/getStats",
queryKey: ["dashboardStats"],
pollIntervalMs: 300000,
purpose: "Top-level KPI cards shown at the top of the dashboard.",
responseShape: {
todayIncome: "number",
dayIncomeGrowth: "number",
currentMonthIncome: "number",
monthIncomeGrowth: "number",
ticketPendingTotal: "number",
commissionPendingTotal: "number",
currentMonthNewUsers: "number",
userGrowth: "number",
totalUsers: "number",
activeUsers: "number",
monthTraffic: {
upload: "number",
download: "number",
},
todayTraffic: {
upload: "number",
download: "number",
},
},
},
{
name: "stat.getOrder",
method: "GET",
path: "{secure}/stat/getOrder",
queryKey: ["orderStat", "{start_date,end_date}"],
pollIntervalMs: 30000,
purpose: "Income and commission summary plus chart series for the overview analytics card.",
params: {
start_date: "yyyy-MM-dd",
end_date: "yyyy-MM-dd",
},
responseShape: {
summary: {
start_date: "string",
end_date: "string",
paid_total: "number",
paid_count: "number",
avg_paid_amount: "number",
commission_total: "number",
commission_count: "number",
commission_rate: "number",
},
list: [
{
date: "date-like string",
paid_total: "number",
commission_total: "number",
paid_count: "number",
commission_count: "number",
},
],
},
},
{
name: "stat.getTrafficRank",
method: "GET",
path: "{secure}/stat/getTrafficRank",
queryKey: ["nodeTrafficRank", "{start}", "{end}"] ,
pollIntervalMs: 30000,
purpose: "Node and user traffic ranking lists with current value, previous value, and delta.",
params: {
type: '"node" | "user"',
start_time: "unix seconds",
end_time: "unix seconds",
},
responseShape: [
{
id: "string | number",
name: "string",
value: "number",
previousValue: "number",
change: "number",
},
],
notes: [
"The compiled page tolerates both raw arrays and { data: [...] } payloads via a normalizer.",
"Node and user rankings maintain separate time-range states and separate queries.",
],
},
{
name: "config.getQueueStats",
method: "GET",
path: "{secure}/system/getQueueStats",
queryKey: ["queueStats", "{refreshToken}"],
pollIntervalMs: 30000,
purpose: "Queue health panel, throughput metrics, failed job count, and active process summary.",
responseShape: {
status: "boolean",
wait: {
default: "number",
},
recentJobs: "number",
jobsPerMinute: "number",
failedJobs: "number",
processes: "number",
pausedMasters: "number",
periods: {
recentJobs: "number",
failedJobs: "number",
},
queueWithMaxThroughput: {
throughput: "number",
},
queueWithMaxRuntime: {
runtime: "number",
name: "string",
},
},
},
{
name: "config.getHorizonFailedJobs",
method: "GET",
path: "{secure}/system/getHorizonFailedJobs",
queryKey: ["failedJobs", "{current}", "{page_size}"],
enabledWhen: "Only fetched after the failed jobs dialog is opened.",
purpose: "Paginated failed-job table and detail modal.",
params: {
current: "1-based page number",
page_size: "number",
},
responseShape: {
data: [
{
id: "string",
failed_at: "timestamp | string",
queue: "string",
connection: "string",
name: "string",
exception: "string",
payload: "stringified JSON or plain string",
},
],
total: "number",
},
},
],
stateModel: {
statsCards: {
queryKey: ["dashboardStats"],
loadingState: "Eight skeleton cards arranged as a 2x4 responsive grid.",
},
orderOverview: {
metricMode: {
default: "amount",
options: ["amount", "count"],
},
rangePreset: {
default: "30d",
options: ["7d", "30d", "90d", "180d", "365d", "custom"],
},
customRange: {
defaultFrom: "today - 7 days",
defaultTo: "today",
},
},
trafficRank: {
nodeRange: {
default: "today",
options: ["today", "last7days", "last30days", "custom"],
},
userRange: {
default: "today",
options: ["today", "last7days", "last30days", "custom"],
},
nodeCustomRange: {
defaultFrom: "today - 7 days",
defaultTo: "today",
},
userCustomRange: {
defaultFrom: "today - 7 days",
defaultTo: "today",
},
},
queue: {
refreshToken: "Manual refresh increments a token in the query key to force refetch.",
failedJobsDialog: {
open: false,
page: 1,
pageSize: 10,
},
selectedFailedJob: null,
jobDetailDialogOpen: false,
},
},
sections: [
{
key: "stats-cards",
titleKey: "dashboard:title",
componentHint: "x$t",
layout: "Responsive 2/4-column card grid.",
cards: [
{
key: "todayIncome",
titleKey: "dashboard:stats.todayIncome",
valueField: "todayIncome",
formatter: "currency via DS",
trendField: "dayIncomeGrowth",
trendLabelKey: "dashboard:stats.vsYesterday",
},
{
key: "currentMonthIncome",
titleKey: "dashboard:stats.monthlyIncome",
valueField: "currentMonthIncome",
formatter: "currency via DS",
trendField: "monthIncomeGrowth",
trendLabelKey: "dashboard:stats.vsLastMonth",
},
{
key: "ticketPendingTotal",
titleKey: "dashboard:stats.pendingTickets",
valueField: "ticketPendingTotal",
descriptionKeys: [
"dashboard:stats.hasPendingTickets",
"dashboard:stats.noPendingTickets",
],
highlightWhen: "ticketPendingTotal > 0",
clickAction: "navigate:/user/ticket",
},
{
key: "commissionPendingTotal",
titleKey: "dashboard:stats.pendingCommission",
valueField: "commissionPendingTotal",
descriptionKeys: [
"dashboard:stats.hasPendingCommission",
"dashboard:stats.noPendingCommission",
],
highlightWhen: "commissionPendingTotal > 0",
clickAction:
"navigate:/finance/order?commission_status=0&status=3&commission_balance=gt:0",
},
{
key: "currentMonthNewUsers",
titleKey: "dashboard:stats.monthlyNewUsers",
valueField: "currentMonthNewUsers",
trendField: "userGrowth",
trendLabelKey: "dashboard:stats.vsLastMonth",
},
{
key: "totalUsers",
titleKey: "dashboard:stats.totalUsers",
valueField: "totalUsers",
descriptionTemplateKey: "dashboard:stats.activeUsers",
descriptionField: "activeUsers",
},
{
key: "monthlyUpload",
titleKey: "dashboard:stats.monthlyUpload",
valueField: "monthTraffic.upload",
formatter: "traffic via IS",
descriptionTemplateKey: "dashboard:stats.todayTraffic",
descriptionField: "todayTraffic.upload",
},
{
key: "monthlyDownload",
titleKey: "dashboard:stats.monthlyDownload",
valueField: "monthTraffic.download",
formatter: "traffic via IS",
descriptionTemplateKey: "dashboard:stats.todayTraffic",
descriptionField: "todayTraffic.download",
},
],
},
{
key: "order-overview",
titleKey: "dashboard:overview.title",
componentHint: "t$t",
layout: "Summary header plus a 400px chart area.",
summaryFields: [
"summary.start_date",
"summary.end_date",
"summary.paid_total",
"summary.paid_count",
"summary.avg_paid_amount",
"summary.commission_total",
"summary.commission_count",
"summary.commission_rate",
],
seriesMapping: {
amountMode: ["paid_total", "commission_total"],
countMode: ["paid_count", "commission_count"],
},
chartBehavior: {
amountMode: "Dual area chart with gradient fills.",
countMode: "Dual bar chart with shared x-axis.",
xAxisField: "date",
},
},
{
key: "traffic-rank",
titleKey: "dashboard:trafficRank.nodeTrafficRank / dashboard:trafficRank.userTrafficRank",
componentHint: "dqt",
layout: "Two side-by-side cards on desktop, each using a scroll area with tooltip details.",
lists: [
{
key: "node",
requestType: "node",
maxBarWidthSource: "highest current value in the current result set",
},
{
key: "user",
requestType: "user",
maxBarWidthSource: "highest current value in the current result set",
},
],
itemFields: ["id", "name", "value", "previousValue", "change"],
tooltipFields: [
"dashboard:trafficRank.currentTraffic",
"dashboard:trafficRank.previousTraffic",
"dashboard:trafficRank.changeRate",
],
},
{
key: "queue-status",
titleKey: "dashboard:queue.title",
componentHint: "eGt",
layout: "Two cards in a responsive 2-column grid plus two dialogs.",
cards: [
"running-status",
"recent-jobs",
"jobs-per-minute",
"failed-jobs-7-days",
"longest-running-queue",
"active-processes",
],
tableColumns: ["failed_at", "queue", "name", "exception", "actions"],
detailDialogFields: [
"id",
"failed_at",
"queue",
"connection",
"name",
"exception",
"payload",
],
},
],
interactions: [
"Pending ticket KPI navigates directly to /user/ticket.",
"Pending commission KPI navigates to /finance/order with commission_status=0, status=3, commission_balance=gt:0.",
"Overview analytics supports preset date windows plus a custom date-range picker.",
"Traffic ranking supports independent node/user filters and treats custom ranges as inclusive by extending the end date by one day before unix conversion.",
"Queue card refresh button bumps a local refresh token and lets react-query refetch immediately.",
"Failed-job rows open a dedicated detail dialog; payload is pretty-printed as JSON when possible.",
],
recoveredFields: {
orderStatusEnum: {
PENDING: 0,
PROCESSING: 1,
CANCELLED: 2,
COMPLETED: 3,
DISCOUNTED: 4,
},
commissionStatusEnum: {
PENDING: 0,
PROCESSING: 1,
VALID: 2,
INVALID: 3,
},
},
notes: [
"The route factory renders dashboard header controls before the four live sections.",
"The compiled bundle localizes labels through translation keys rather than hard-coded copy.",
"Traffic rank payloads are normalized through a helper that accepts either a bare array or an object containing data.",
],
sourceRefs: [
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:263492",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:264031",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:264772",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:268829",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:269427",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:27302",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:27417",
],
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Sign In",
routePath: "/sign-in",
moduleId: "Vot",
featureKey: "auth.sign_in",
notes: ["Compiled login page route extracted from dist router."],
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Knowledge Management",
routePath: "/config/knowledge",
moduleId: "a4t",
featureKey: "config.knowledge",
notes: ["Includes knowledge article fetch, edit, preview, and sort flows."],
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Notice Management",
routePath: "/config/notice",
moduleId: "J2t",
featureKey: "config.notice",
notes: ["Includes markdown-like editor behavior and sorting actions."],
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Payment Config",
routePath: "/config/payment",
moduleId: "kXt",
featureKey: "config.payment",
notes: ["Uses payment.fetch/getPaymentMethods/getPaymentForm/save/drop/show/sort."],
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Plugin Management",
routePath: "/config/plugin",
moduleId: "jQt",
featureKey: "config.plugin",
notes: ["Bundle contains plugin list, type, install, upgrade, enable/disable, upload, delete flows."],
});

View File

@@ -0,0 +1,10 @@
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
import ThemeConfigPageView from "./ThemeConfigPage.jsx";
export default makeRecoveredPage({
title: "Nebula Theme",
routePath: "/config/theme",
moduleId: "WQt",
featureKey: "config.theme",
component: ThemeConfigPageView,
});

View File

@@ -0,0 +1,284 @@
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
import { requestJson } from "../../runtime/client.js";
function Wot({ children }) {
return <div className="flex h-full w-full flex-col">{children}</div>;
}
function Hot({ children }) {
return (
<div className="flex h-[var(--header-height)] flex-none items-center justify-between gap-4 bg-background p-4 md:px-8">
{children}
</div>
);
}
function zot({ children }) {
return <div className="flex-1 overflow-hidden px-4 py-6 md:px-8">{children}</div>;
}
function Card({ title, description, children }) {
return (
<div className="rounded-xl border bg-card text-card-foreground shadow">
<div className="flex flex-col space-y-1.5 p-6">
<h3 className="font-semibold leading-none tracking-tight">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="p-6 pt-0">{children}</div>
</div>
);
}
function Field({ label, description, children, span = 1 }) {
return (
<div className={span === 2 ? "space-y-2 md:col-span-2" : "space-y-2"}>
<label className="text-sm font-medium leading-none">{label}</label>
{children}
<p className="text-[0.8rem] text-muted-foreground">{description}</p>
</div>
);
}
const defaults = {
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 themeColorOptions = [
{ value: "aurora", label: "Aurora" },
{ value: "sunset", label: "Sunset" },
{ value: "ember", label: "Ember" },
{ value: "violet", label: "Violet" },
];
const themeModeOptions = [
{ value: "system", label: "Follow system" },
{ value: "dark", label: "Prefer dark" },
{ value: "light", label: "Prefer light" },
];
function QKt() {
const [form, setForm] = useState(defaults);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [messageType, setMessageType] = useState("");
useEffect(() => {
(async () => {
try {
const payload = await requestJson("/config/fetch?key=nebula");
const nebula = payload?.data?.nebula || payload?.nebula || {};
setForm((prev) => ({ ...prev, ...nebula }));
} catch (error) {
setMessage(error?.message || "Failed to load Nebula settings");
setMessageType("error");
} finally {
setLoading(false);
}
})();
}, []);
const updateField = (key, value) => {
setForm((prev) => ({ ...prev, [key]: value ?? "" }));
};
const save = async () => {
setSaving(true);
setMessage("");
setMessageType("");
try {
await requestJson("/config/save", { method: "POST", body: form });
setMessage("Nebula settings saved");
setMessageType("success");
} catch (error) {
setMessage(error?.message || "Failed to save Nebula settings");
setMessageType("error");
} finally {
setSaving(false);
}
};
return (
<Wot>
<Hot>
<div />
<div className="flex items-center gap-3">
<button
type="button"
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
onClick={save}
disabled={loading || saving}
>
{saving ? "Saving..." : "Save settings"}
</button>
</div>
</Hot>
<zot>
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">Nebula Theme</h1>
<p className="text-muted-foreground">
Configure Nebula theme colors, copywriting, branding assets, and custom injections.
</p>
</header>
{message ? (
<div
className={
messageType === "success"
? "rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600"
: "rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
}
>
{message}
</div>
) : null}
<Card
title="Display"
description="Set the primary color, theme mode, and hero copy shown by Nebula."
>
{loading ? (
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground">
Loading Nebula settings...
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Theme palette" description="Controls the default Nebula color palette.">
<select
className="flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_theme_color}
onChange={(event) => updateField("nebula_theme_color", event.target.value)}
>
{themeColorOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</Field>
<Field
label="Theme mode"
description="Applied when a user opens the Nebula frontend for the first time."
>
<select
className="flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_default_theme_mode}
onChange={(event) =>
updateField("nebula_default_theme_mode", event.target.value)
}
>
{themeModeOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</Field>
<Field label="Hero slogan" description="Shown as the main title in the hero area.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_hero_slogan}
onChange={(event) => updateField("nebula_hero_slogan", event.target.value)}
/>
</Field>
<Field label="Welcome target" description="Appended after the Welcome to heading.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_welcome_target}
onChange={(event) => updateField("nebula_welcome_target", event.target.value)}
/>
</Field>
<Field label="Register title" description="Displayed at the top of the register panel.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_register_title}
onChange={(event) => updateField("nebula_register_title", event.target.value)}
/>
</Field>
<Field
label="Metrics API URL"
description="Base URL used when Nebula shows public metrics before login."
>
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_metrics_base_url}
onChange={(event) => updateField("nebula_metrics_base_url", event.target.value)}
/>
</Field>
</div>
)}
</Card>
<Card
title="Branding"
description="Set background assets, logo URLs, CDN roots, and custom HTML."
>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Background URL" description="Used by the Nebula login or landing background.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_background_url}
onChange={(event) => updateField("nebula_background_url", event.target.value)}
/>
</Field>
<Field label="Static CDN URL" description="Root CDN path for Nebula static assets.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_static_cdn_url}
onChange={(event) => updateField("nebula_static_cdn_url", event.target.value)}
/>
</Field>
<Field label="Light logo URL" description="Displayed while the light theme is active.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_light_logo_url}
onChange={(event) => updateField("nebula_light_logo_url", event.target.value)}
/>
</Field>
<Field label="Dark logo URL" description="Displayed while the dark theme is active.">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_dark_logo_url}
onChange={(event) => updateField("nebula_dark_logo_url", event.target.value)}
/>
</Field>
<Field
label="Custom HTML / scripts"
description="Inject custom HTML, scripts, or styles into Nebula pages."
span={2}
>
<textarea
className="min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={form.nebula_custom_html}
onChange={(event) => updateField("nebula_custom_html", event.target.value)}
/>
</Field>
</div>
</Card>
</div>
</zot>
</Wot>
);
}
export default QKt;

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Subscribe Template",
routePath: "/config/system/subscribe-template",
moduleId: "LQe",
featureKey: "config.system.subscribe_template",
notes: ["Inline route in the compiled router instead of lazy chunk."],
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System App",
routePath: "/config/system/app",
moduleId: "JGt",
featureKey: "config.system.app",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Email",
routePath: "/config/system/email",
moduleId: "$Gt",
featureKey: "config.system.email",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Frontend",
routePath: "/config/system/frontend",
moduleId: "PGt",
featureKey: "config.system.frontend",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Invite",
routePath: "/config/system/invite",
moduleId: "RGt",
featureKey: "config.system.invite",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Overview",
routePath: "/config/system",
moduleId: "yGt",
featureKey: "config.system.overview",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Safe",
routePath: "/config/system/safe",
moduleId: "SGt",
featureKey: "config.system.safe",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Server",
routePath: "/config/system/server",
moduleId: "WGt",
featureKey: "config.system.server",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Subscribe",
routePath: "/config/system/subscribe",
moduleId: "LGt",
featureKey: "config.system.subscribe",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "System Telegram",
routePath: "/config/system/telegram",
moduleId: "ZGt",
featureKey: "config.system.telegram",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Error 404",
routePath: "/404",
moduleId: "Dm",
featureKey: "error.404",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Error 500",
routePath: "/500",
moduleId: "Lm",
featureKey: "error.500",
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Error 503",
routePath: "/503",
moduleId: "inline-503",
featureKey: "error.503",
notes: ["Compiled route includes an inline component instead of a lazy module."],
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Finance Coupon",
routePath: "/finance/coupon",
moduleId: "z3t",
featureKey: "finance.coupon",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Finance Gift Card",
routePath: "/finance/gift-card",
moduleId: "d6t",
featureKey: "finance.gift_card",
});

View File

@@ -0,0 +1,322 @@
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
import FinanceOrderPageView from "./FinanceOrderPage.jsx";
export default makeRecoveredPage({
title: "Finance Order",
routePath: "/finance/order",
moduleId: "R3t",
featureKey: "finance.order",
component: FinanceOrderPageView,
translationNamespaces: ["order", "common"],
summary:
"The order management page is a server-driven data table with a lightweight create/assign dialog, multi-dimensional faceted filters, detail dialog drill-down, pending-order action menu, and commission workflow updates.",
api: [
{
name: "order.fetch",
method: "POST",
path: "{secure}/order/fetch",
queryKey: ["orderList", "{pagination}", "{filters}", "{sorting}"],
purpose: "Loads paginated order table data for the main management view.",
requestShape: {
pageSize: "number",
current: "1-based page number",
filter: [{ id: "column id", value: "raw value or operator:value string" }],
sort: [{ id: "column id", desc: "boolean" }],
},
responseShape: {
data: [
{
id: "number",
trade_no: "string",
type: "number",
period: "string",
status: "number",
total_amount: "number",
balance_amount: "number",
discount_amount: "number",
refund_amount: "number",
surplus_amount: "number",
commission_balance: "number",
commission_status: "number",
created_at: "unix timestamp",
updated_at: "unix timestamp",
callback_no: "string | null",
user: {
id: "number",
email: "string",
},
plan: {
id: "number",
name: "string",
},
invite_user: {
id: "number",
email: "string",
},
},
],
total: "number",
},
},
{
name: "order.detail",
method: "POST",
path: "{secure}/order/detail",
purpose: "Loads the order detail dialog on demand when the trade number trigger is opened.",
payloadExamples: [{ id: 1001 }],
},
{
name: "order.paid",
method: "POST",
path: "{secure}/order/paid",
purpose: "Marks a pending order as paid from the row action menu.",
payloadExamples: [{ trade_no: "202604170001" }],
},
{
name: "order.cancel",
method: "POST",
path: "{secure}/order/cancel",
purpose: "Cancels a pending order from the row action menu.",
payloadExamples: [{ trade_no: "202604170001" }],
},
{
name: "order.update",
method: "POST",
path: "{secure}/order/update",
purpose: "Transitions commission workflow state from the commission status action menu.",
payloadExamples: [
{ trade_no: "202604170001", commission_status: 1 },
{ trade_no: "202604170001", commission_status: 3 },
],
},
{
name: "order.assign",
method: "POST",
path: "{secure}/order/assign",
purpose: "Creates an admin-assigned order from the toolbar dialog or user-row embedded trigger.",
payloadExamples: [
{
email: "user@example.com",
plan_id: 1,
period: "month_price",
total_amount: 1999,
},
],
},
{
name: "plan.fetch",
method: "GET",
path: "{secure}/plan/fetch",
purpose: "Supplies plan options for the assign-order dialog.",
},
],
enums: {
orderStatus: {
PENDING: 0,
PROCESSING: 1,
CANCELLED: 2,
COMPLETED: 3,
DISCOUNTED: 4,
},
orderType: {
NEW: 1,
RENEWAL: 2,
UPGRADE: 3,
RESET_FLOW: 4,
},
commissionStatus: {
PENDING: 0,
PROCESSING: 1,
VALID: 2,
INVALID: 3,
},
periodKeys: [
"month_price",
"quarter_price",
"half_year_price",
"year_price",
"two_year_price",
"three_year_price",
"onetime_price",
"reset_price",
],
},
stateModel: {
list: {
rowSelection: {},
columnVisibility: {},
columnFilters: [],
sorting: [],
pagination: {
pageIndex: 0,
pageSize: 20,
},
initialColumnPinning: {
right: ["actions"],
},
},
routeBootstrap: {
searchParamsToFilters: [
{ key: "user_id", parser: "string" },
{ key: "order_id", parser: "string" },
{ key: "commission_status", parser: "number" },
{ key: "status", parser: "number" },
{ key: "commission_balance", parser: "string" },
],
},
assignDialog: {
open: false,
defaultValues: {
email: "",
plan_id: 0,
period: "",
total_amount: 0,
},
planOptions: "Loaded lazily when the dialog is opened.",
},
detailDialog: {
open: false,
orderId: null,
data: null,
},
},
toolbarModel: {
quickActions: [
"Assign/create order dialog button",
"Trade number search on trade_no column",
],
facetedFilters: [
{
column: "type",
source: "order type enum",
},
{
column: "period",
source: "order period enum",
},
{
column: "status",
source: "order status enum",
},
{
column: "commission_status",
source: "commission status enum",
},
],
resetBehavior: "Reset button appears when any column filter exists and clears all filters.",
},
tableModel: {
columns: [
{
key: "trade_no",
titleKey: "table.columns.tradeNo",
renderer:
"Truncated trade number button that opens an on-demand detail dialog for the current order.",
},
{
key: "type",
titleKey: "table.columns.type",
renderer: "Enum badge with per-type color token mapping.",
},
{
key: "plan.name",
titleKey: "table.columns.plan",
renderer: "Plan name text cell using nested plan relation.",
},
{
key: "period",
titleKey: "table.columns.period",
renderer: "Period badge using period color mapping.",
},
{
key: "total_amount",
titleKey: "table.columns.amount",
renderer: "Currency text using fen-to-yuan formatting.",
sortable: true,
},
{
key: "status",
titleKey: "table.columns.status",
renderer:
"Status cell with icon + text. Pending rows expose a dropdown action menu for mark-as-paid and cancel.",
sortable: true,
},
{
key: "commission_balance",
titleKey: "table.columns.commission",
renderer: "Currency text, hidden when absent.",
sortable: true,
},
{
key: "commission_status",
titleKey: "table.columns.commissionStatus",
renderer:
"Commission status badge with conditional action menu. Only shown as actionable when commission_balance is non-zero and order status is COMPLETED.",
sortable: true,
},
{
key: "created_at",
titleKey: "table.columns.createdAt",
renderer: "Timestamp rendered as yyyy/MM/dd HH:mm:ss.",
sortable: true,
},
],
mobileLayout: {
primaryField: "trade_no",
gridFields: ["plan.name", "total_amount", "status", "created_at"],
},
},
dialogs: {
assignOrder: {
formFields: ["email", "plan_id", "period", "total_amount"],
amountBehavior: "UI edits in yuan, submitted as fen by multiplying the numeric input by 100.",
successBehavior: "Refetch list, reset form, close dialog, show success toast.",
},
orderDetail: {
sections: [
"basicInfo",
"amountInfo",
"timeInfo",
"commissionInfo",
],
fields: [
"user.email",
"period",
"plan.name",
"callback_no",
"total_amount",
"balance_amount",
"discount_amount",
"refund_amount",
"surplus_amount",
"created_at",
"updated_at",
"commission_balance",
"commission_status",
],
links: [
"user email links to /user/manage?email=<email>",
"invite user email links to /user/manage?email=<email> when present",
],
},
},
interactions: [
"Opening the page with query parameters such as user_id, status, or commission_status preloads matching column filters from the URL.",
"Trade number cells are the primary detail entrypoint and fetch full order data only when the dialog opens.",
"Pending orders expose mark-as-paid and cancel actions from the status dropdown menu.",
"Pending commissions on completed orders expose PROCESSING and INVALID transitions through order.update.",
"Assign order dialog is reused from user-row actions and prefilled with the current user email when launched there.",
],
notes: [
"The list is fully server-driven: pagination, filtering, and sorting all flow back through order.fetch.",
"The compiled page uses the shared generic faceted-filter popover component for type/period/status/commission filters.",
"Actions are concentrated on status and commission_status columns instead of a dedicated trailing actions column.",
],
sourceRefs: [
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:27362",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:296650",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:296936",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:297228",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:297575",
],
});

View File

@@ -0,0 +1,421 @@
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
import {
compactText,
formatCurrencyFen,
formatUnixSeconds,
requestJson,
} from "../../runtime/client.js";
const ORDER_PERIODS = [
"month_price",
"quarter_price",
"half_year_price",
"year_price",
"two_year_price",
"three_year_price",
"onetime_price",
"reset_price",
];
function FinanceOrderPage() {
const [loading, setLoading] = useState(true);
const [refreshTick, setRefreshTick] = useState(0);
const [searchInput, setSearchInput] = useState("");
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [rows, setRows] = useState([]);
const [pagination, setPagination] = useState({ current: 1, last_page: 1, per_page: 20, total: 0 });
const [plans, setPlans] = useState([]);
const [modal, setModal] = useState(null);
const [assignForm, setAssignForm] = useState({ email: "", plan_id: "", period: "", total_amount: "" });
const [detail, setDetail] = useState(null);
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
(async () => {
try {
const payload = await requestJson("/plan/fetch");
if (!active) {
return;
}
setPlans(Array.isArray(payload?.data) ? payload.data : payload?.list || payload || []);
} catch (err) {
if (active) {
setNotice(err.message || "Plan list unavailable");
}
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
setError("");
try {
const query = new URLSearchParams({
page: String(pagination.current || 1),
per_page: String(pagination.per_page || 20),
});
if (keyword) {
query.set("keyword", keyword);
}
if (status !== "") {
query.set("status", status);
}
const payload = await requestJson(`/order/fetch?${query.toString()}`);
if (!active) {
return;
}
setRows(Array.isArray(payload?.list) ? payload.list : []);
setPagination(payload?.pagination || pagination);
} catch (err) {
if (active) {
setError(err.message || "Failed to load orders");
setRows([]);
}
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
}, [keyword, pagination.current, pagination.per_page, refreshTick, status]);
const reload = () => setRefreshTick((value) => value + 1);
const openDetail = async (order) => {
setModal({ type: "detail", title: order.trade_no });
setDetail(null);
try {
const payload = await requestJson("/order/detail", {
method: "POST",
body: { trade_no: order.trade_no },
});
setDetail(payload?.data || payload);
} catch (err) {
setDetail({ error: err.message || "Failed to load detail" });
}
};
const submitSearch = (event) => {
event.preventDefault();
setPagination((current) => ({ ...current, current: 1 }));
setKeyword(compactText(searchInput));
};
const markPaid = async (tradeNo) => {
if (!window.confirm(`Mark ${tradeNo} as paid?`)) {
return;
}
try {
await requestJson("/order/paid", { method: "POST", body: { trade_no: tradeNo } });
reload();
} catch (err) {
setError(err.message || "Failed to mark order paid");
}
};
const cancelOrder = async (tradeNo) => {
if (!window.confirm(`Cancel ${tradeNo}?`)) {
return;
}
try {
await requestJson("/order/cancel", { method: "POST", body: { trade_no: tradeNo } });
reload();
} catch (err) {
setError(err.message || "Failed to cancel order");
}
};
const saveAssign = async (event) => {
event.preventDefault();
try {
await requestJson("/order/assign", {
method: "POST",
body: {
email: compactText(assignForm.email),
plan_id: toNullableNumber(assignForm.plan_id),
period: compactText(assignForm.period),
total_amount: toNullableNumber(assignForm.total_amount),
},
});
setModal(null);
reload();
} catch (err) {
setError(err.message || "Failed to assign order");
}
};
const saveCommissionStatus = async (tradeNo, commissionStatus) => {
try {
await requestJson("/order/update", {
method: "POST",
body: { trade_no: tradeNo, commission_status: commissionStatus },
});
reload();
} catch (err) {
setError(err.message || "Failed to update commission state");
}
};
return (
<div className="recovery-live-page">
<header className="recovery-live-hero">
<div>
<p className="eyebrow">Recovered React Page</p>
<h2>Finance Order</h2>
<p>Live order table with detail drill-down, manual payment actions, and commission state updates.</p>
</div>
<div className="hero-stats">
<div><strong>{pagination.total}</strong><span>orders</span></div>
<div><strong>{pagination.current}</strong><span>page</span></div>
<div><strong>{plans.length}</strong><span>plans</span></div>
</div>
</header>
<section className="recovery-toolbar">
<form className="recovery-search" onSubmit={submitSearch}>
<input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Search by trade no or user ID"
/>
<button type="submit">Search</button>
</form>
<select value={status} onChange={(event) => { setPagination((current) => ({ ...current, current: 1 })); setStatus(event.target.value); }}>
<option value="">All statuses</option>
<option value="0">Pending</option>
<option value="2">Cancelled</option>
<option value="3">Paid</option>
</select>
<button
type="button"
className="secondary"
onClick={() => {
setSearchInput("");
setKeyword("");
setStatus("");
setPagination((current) => ({ ...current, current: 1 }));
}}
>
Clear
</button>
<button
type="button"
onClick={() => {
setAssignForm({ email: "", plan_id: "", period: "", total_amount: "" });
setModal({ type: "assign", title: "Assign order" });
}}
>
Assign
</button>
</section>
{notice ? <div className="toast toast-info">{notice}</div> : null}
{error ? <div className="toast toast-error">{error}</div> : null}
<section className="recovery-table-card">
<div className="table-scroll">
<table className="recovery-table">
<thead>
<tr>
<th>Trade No</th>
<th>User</th>
<th>Plan</th>
<th>Period</th>
<th>Amount</th>
<th>Status</th>
<th>Commission</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} className="empty-cell">Loading...</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={9} className="empty-cell">No orders found</td></tr>
) : rows.map((order) => (
<tr key={order.trade_no}>
<td>
<button type="button" className="link-button" onClick={() => openDetail(order)}>
{order.trade_no}
</button>
</td>
<td>
<div className="cell-stack">
<strong>{order.user_email || `User ${order.user_id}`}</strong>
<span className="muted">UID: {order.user_id}</span>
</div>
</td>
<td>{order.plan?.name || order.plan_name || `Plan ${order.plan_id || "-"}`}</td>
<td>{order.period || "-"}</td>
<td>{formatCurrencyFen(order.total_amount)}</td>
<td><span className={`chip ${statusClass(order.status)}`}>{statusLabel(order.status)}</span></td>
<td>
<div className="cell-stack">
<span>{commissionStatusLabel(order.commission_status)}</span>
<span className="muted">{formatCurrencyFen(order.commission_balance)}</span>
</div>
</td>
<td>{formatUnixSeconds(order.created_at)}</td>
<td>
<div className="action-row">
{Number(order.status) === 0 ? (
<>
<button type="button" onClick={() => markPaid(order.trade_no)}>Paid</button>
<button type="button" className="danger" onClick={() => cancelOrder(order.trade_no)}>Cancel</button>
</>
) : null}
<button type="button" onClick={() => saveCommissionStatus(order.trade_no, 2)}>Valid</button>
<button type="button" onClick={() => saveCommissionStatus(order.trade_no, 3)}>Invalid</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination-bar">
<button
type="button"
disabled={pagination.current <= 1}
onClick={() => setPagination((current) => ({ ...current, current: Math.max(1, current.current - 1) }))}
>
Prev
</button>
<span>
Page {pagination.current} / {pagination.last_page}
</span>
<button
type="button"
disabled={pagination.current >= pagination.last_page}
onClick={() => setPagination((current) => ({ ...current, current: Math.min(current.last_page, current.current + 1) }))}
>
Next
</button>
</div>
</section>
{modal?.type === "assign" ? (
<Modal title="Assign order" onClose={() => setModal(null)}>
<form className="modal-grid" onSubmit={saveAssign}>
<label className="span-2">
<span>Email</span>
<input value={assignForm.email} onChange={(event) => setAssignForm((current) => ({ ...current, email: event.target.value }))} />
</label>
<label>
<span>Plan</span>
<select value={assignForm.plan_id} onChange={(event) => setAssignForm((current) => ({ ...current, plan_id: event.target.value }))}>
<option value="">-</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>{plan.name || `Plan ${plan.id}`}</option>
))}
</select>
</label>
<label>
<span>Period</span>
<select value={assignForm.period} onChange={(event) => setAssignForm((current) => ({ ...current, period: event.target.value }))}>
<option value="">-</option>
{ORDER_PERIODS.map((period) => (
<option key={period} value={period}>{period}</option>
))}
</select>
</label>
<label className="span-2">
<span>Total Amount</span>
<input type="number" value={assignForm.total_amount} onChange={(event) => setAssignForm((current) => ({ ...current, total_amount: event.target.value }))} />
</label>
<div className="modal-actions span-2">
<button type="button" className="secondary" onClick={() => setModal(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</Modal>
) : null}
{modal?.type === "detail" ? (
<Modal title={`Order detail ${modal.title}`} onClose={() => setModal(null)}>
{detail?.error ? <div className="toast toast-error">{detail.error}</div> : null}
<pre className="detail-json">{JSON.stringify(detail, null, 2)}</pre>
</Modal>
) : null}
</div>
);
}
function Modal({ title, children, onClose }) {
return (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal-panel modal-panel-wide" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<header className="modal-header">
<h3>{title}</h3>
<button type="button" className="secondary" onClick={onClose}>Close</button>
</header>
{children}
</div>
</div>
);
}
function statusLabel(status) {
switch (Number(status)) {
case 0:
return "Pending";
case 2:
return "Cancelled";
case 3:
return "Paid";
default:
return String(status ?? "-");
}
}
function statusClass(status) {
switch (Number(status)) {
case 0:
return "chip-warn";
case 2:
return "chip-danger";
case 3:
return "chip-ok";
default:
return "chip-soft";
}
}
function commissionStatusLabel(status) {
switch (Number(status)) {
case 0:
return "Pending";
case 1:
return "Processing";
case 2:
return "Valid";
case 3:
return "Invalid";
default:
return String(status ?? "-");
}
}
function toNullableNumber(value) {
const trimmed = compactText(value);
if (!trimmed) {
return undefined;
}
const number = Number(trimmed);
return Number.isNaN(number) ? undefined : number;
}
export default FinanceOrderPage;

View File

@@ -0,0 +1,312 @@
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
export default makeRecoveredPage({
title: "Finance Plan",
routePath: "/finance/plan",
moduleId: "p3t",
featureKey: "finance.plan",
translationNamespaces: ["subscribe", "common"],
summary:
"The plan management page is already fully visible in the compiled bundle: it loads the complete plan table, supports inline visibility/sale/renew toggles, drag-sort persistence, modal add/edit, delete confirmation, markdown content editing, and server-group binding.",
api: [
{
name: "plan.fetch",
method: "GET",
path: "{secure}/plan/fetch",
queryKey: ["planList"],
purpose: "Loads the entire plan table used by desktop and mobile layouts.",
responseShape: [
{
id: "number",
show: "boolean",
sell: "boolean",
renew: "boolean",
name: "string",
tags: ["string"],
group: {
id: "number",
name: "string",
},
users_count: "number",
active_users_count: "number",
transfer_enable: "number",
speed_limit: "number | null",
capacity_limit: "number | null",
device_limit: "number | null",
reset_traffic_method: "number | null",
content: "string",
prices: {
monthly: "number | null",
quarterly: "number | null",
half_yearly: "number | null",
yearly: "number | null",
two_yearly: "number | null",
three_yearly: "number | null",
onetime: "number | null",
reset_traffic: "number | null",
},
},
],
},
{
name: "plan.save",
method: "POST",
path: "{secure}/plan/save",
purpose: "Submits both add and edit modal payloads.",
notes: [
"The compiled dialog uses the same save endpoint for create and edit.",
"The currently edited row is distinguished by the presence of id in the form payload.",
],
},
{
name: "plan.update",
method: "POST",
path: "{secure}/plan/update",
purpose: "Inline boolean updates for show/sell/renew toggles.",
payloadExamples: [{ id: 1, show: true }, { id: 1, sell: false }, { id: 1, renew: true }],
},
{
name: "plan.drop",
method: "POST",
path: "{secure}/plan/drop",
purpose: "Deletes a plan after confirmation.",
payloadExamples: [{ id: 1 }],
},
{
name: "plan.sort",
method: "POST",
path: "{secure}/plan/sort",
purpose: "Persists drag-and-drop ordering when sort mode is saved.",
payloadExamples: [{ ids: [1, 3, 2] }],
},
{
name: "server.group.fetch",
method: "GET",
path: "{secure}/server/group/fetch",
purpose: "Populates the group selector inside the add/edit dialog.",
notes: ["The group list is refreshed every time the plan modal opens."],
},
],
stateModel: {
list: {
sorting: [],
columnVisibility: {
"drag-handle": false,
},
rowSelection: {},
columnFilters: [],
pagination: {
pageSize: 20,
pageIndex: 0,
},
draggableRows: false,
localSortedRowsMirror: "A local copy of the fetched plan list is mutated while drag-sort mode is active.",
},
modal: {
isOpen: false,
editingPlan: null,
isSubmitting: false,
previewMarkdown: false,
groupOptions: [],
},
},
tableModel: {
toolbar: {
actions: [
"Add plan button opens the plan dialog through context state.",
"Name search writes directly into the table filter for the name column.",
"Sort button toggles drag-sort mode; a second click persists order through plan.sort.",
],
},
columns: [
{
key: "drag-handle",
visibleWhen: "sort mode only",
role: "Drag handle used by the generic table component callbacks.",
},
{
key: "id",
titleKey: "plan.columns.id",
renderer: "Outline badge",
},
{
key: "show",
titleKey: "plan.columns.show",
renderer: "Boolean switch",
updateApi: "plan.update",
},
{
key: "sell",
titleKey: "plan.columns.sell",
renderer: "Boolean switch",
updateApi: "plan.update",
},
{
key: "renew",
titleKey: "plan.columns.renew",
tooltipKey: "plan.columns.renew_tooltip",
renderer: "Boolean switch",
updateApi: "plan.update",
},
{
key: "name",
titleKey: "plan.columns.name",
renderer: "Long truncated text cell",
},
{
key: "users_count",
titleKey: "plan.columns.stats",
renderer:
"Two compact stat pills: total users_count and active_users_count, with active-rate percentage when users_count > 0.",
},
{
key: "group",
titleKey: "plan.columns.group",
renderer: "Single badge using group.name",
},
{
key: "prices",
titleKey: "plan.columns.price",
renderer:
"Dynamic period badges for each non-null price; shows a dashed no-price badge when no prices exist.",
supportedPeriods: [
"monthly",
"quarterly",
"half_yearly",
"yearly",
"two_yearly",
"three_yearly",
"onetime",
"reset_traffic",
],
},
{
key: "actions",
titleKey: "plan.columns.actions",
renderer: "Edit action + delete confirmation action",
},
],
mobileLayout: {
primaryField: "name",
gridFields: ["prices", "users_count", "group"],
},
},
formModel: {
schema: {
id: "number | null",
group_id: "number | string | null",
name: "string, required, max 250",
tags: "string[] | null",
content: "string | null",
transfer_enable: "number | string, required",
prices: {
monthly: "number | string | null",
quarterly: "number | string | null",
half_yearly: "number | string | null",
yearly: "number | string | null",
two_yearly: "number | string | null",
three_yearly: "number | string | null",
onetime: "number | string | null",
reset_traffic: "number | string | null",
},
speed_limit: "number | string | null",
capacity_limit: "number | string | null",
device_limit: "number | string | null",
force_update: "boolean",
reset_traffic_method: "number | null",
users_count: "number | undefined",
active_users_count: "number | undefined",
},
defaults: {
id: null,
group_id: null,
name: "",
tags: [],
content: "",
transfer_enable: "",
prices: {
monthly: "",
quarterly: "",
half_yearly: "",
yearly: "",
two_yearly: "",
three_yearly: "",
onetime: "",
reset_traffic: "",
},
speed_limit: "",
capacity_limit: "",
device_limit: "",
force_update: false,
reset_traffic_method: null,
},
fields: [
"name",
"tags",
"group_id",
"transfer_enable",
"speed_limit",
"device_limit",
"capacity_limit",
"reset_traffic_method",
"prices.*",
"content",
],
editOnlyControls: ["force_update"],
resetTrafficMethodOptions: [
{ value: null, labelKey: "plan.form.reset_method.options.follow_system" },
{ value: 0, labelKey: "plan.form.reset_method.options.monthly_first" },
{ value: 1, labelKey: "plan.form.reset_method.options.monthly_reset" },
{ value: 2, labelKey: "plan.form.reset_method.options.no_reset" },
{ value: 3, labelKey: "plan.form.reset_method.options.yearly_first" },
{ value: 4, labelKey: "plan.form.reset_method.options.yearly_reset" },
],
markdownEditor: {
templateButton: "plan.form.content.template.button",
templateContentKey: "plan.form.content.template.content",
previewToggleKeys: [
"plan.form.content.preview_button.show",
"plan.form.content.preview_button.hide",
],
notes: [
"The editor is configured for markdown view with menu enabled and HTML view disabled.",
"Preview is rendered with markdown-it using html:true.",
],
},
priceAutoFill: {
trigger: "Base price input",
formula: "basePrice * months * discount, rounded to 2 decimals and applied to all price slots.",
matrix: {
monthly: { months: 1, discount: 1 },
quarterly: { months: 3, discount: 0.95 },
half_yearly: { months: 6, discount: 0.9 },
yearly: { months: 12, discount: 0.85 },
two_yearly: { months: 24, discount: 0.8 },
three_yearly: { months: 36, discount: 0.75 },
onetime: { months: 1, discount: 1 },
reset_traffic: { months: 1, discount: 1 },
},
},
},
interactions: [
"Entering sort mode exposes the drag handle column, disables normal pagination, and inflates pageSize so the full list can be reordered at once.",
"Saving sort mode posts the current local row id order to plan.sort and exits drag mode after success.",
"Deleting a row asks for confirmation and refetches the plan list after a success toast.",
"Opening the edit dialog preloads the selected row into react-hook-form and keeps id in the payload.",
"The group field includes an inline 'add group' dialog trigger and refreshes the available groups afterwards.",
"Validation errors are aggregated into a toast message by joining field error messages with line breaks.",
],
notes: [
"The plan page uses the subscribe translation namespace even though it belongs to finance routing.",
"The table is pinned with the actions column on the right in its initial state.",
"Desktop sorting/search/filter behavior is driven through the shared table abstraction rather than page-local DOM code.",
],
sourceRefs: [
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:27357",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:295538",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:295602",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:295972",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:296385",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:296492",
],
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Server Group",
routePath: "/server/group",
moduleId: "G5t",
featureKey: "server.group",
});

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Server Manage",
routePath: "/server/manage",
moduleId: "U5t",
featureKey: "server.manage",
notes: ["Uses node list, copy, visibility update, sort, delete, and reset traffic flows."],
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Server Route",
routePath: "/server/route",
moduleId: "e3t",
featureKey: "server.route",
});

View File

@@ -0,0 +1,8 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "Traffic Reset Logs",
routePath: "/user/traffic-reset-logs",
moduleId: "V8t",
featureKey: "user.traffic_reset_logs",
});

View File

@@ -0,0 +1,404 @@
import { makeRecoveredPage } from "../../runtime/makeRecoveredPage.js";
import UserManagePageView from "./UserManagePage.jsx";
export default makeRecoveredPage({
title: "User Manage",
routePath: "/user/manage",
moduleId: "u8t",
featureKey: "user.manage",
component: UserManagePageView,
translationNamespaces: ["user", "common", "order", "traffic"],
summary:
"The user management page is one of the heaviest admin pages in the bundle: it combines a server-side data table, advanced filter sheet, batch actions, email sending, user generation, traffic history dialog, inline order assignment, reset-secret/reset-traffic flows, and a full edit side sheet.",
api: [
{
name: "user.fetch",
method: "POST",
path: "{secure}/user/fetch",
queryKey: ["userList", "{pagination}", "{filters}", "{sorting}"],
purpose: "Loads paginated user table data from server-side filters and sorting.",
requestShape: {
pageSize: "number",
current: "1-based page number",
filter: [{ id: "column id", value: "raw value or operator:value string" }],
sort: [{ id: "column id", desc: "boolean" }],
},
responseShape: {
data: [
{
id: "number",
email: "string",
is_admin: "boolean",
is_staff: "boolean",
banned: "boolean",
balance: "number",
commission_balance: "number",
online_count: "number",
device_limit: "number | null",
transfer_enable: "number",
total_used: "number",
u: "number",
d: "number",
expired_at: "unix timestamp | null",
next_reset_at: "unix timestamp | null",
created_at: "unix timestamp",
subscribe_url: "string",
uuid: "string",
token: "string",
remarks: "string | null",
plan: {
id: "number",
name: "string",
},
group: {
id: "number",
name: "string",
},
invite_user: {
id: "number",
email: "string",
},
invite_user_id: "number | null",
commission_type: "number",
commission_rate: "number | null",
},
],
total: "number",
},
},
{
name: "user.update",
method: "POST",
path: "{secure}/user/update",
purpose: "Persists edited user fields from the edit side sheet.",
notes: [
"Only dirty fields are submitted beyond the required id.",
"Upload/download fields are edited in GB in the form and converted back to bytes before submit.",
],
},
{
name: "user.resetSecret",
method: "POST",
path: "{secure}/user/resetSecret",
purpose: "Resets a user's secret/token from the actions menu.",
},
{
name: "user.generate",
method: "POST",
path: "{secure}/user/generate",
purpose: "Bulk generates users from the toolbar dialog, optionally downloading CSV output.",
},
{
name: "user.getStatUser",
method: "POST",
path: "{secure}/stat/getStatUser",
purpose: "Loads per-user traffic history rows for the traffic records dialog.",
},
{
name: "user.destroy",
method: "POST",
path: "{secure}/user/destroy",
purpose: "Deletes a user after confirmation from the row action menu.",
},
{
name: "user.sendMail",
method: "POST",
path: "{secure}/user/sendMail",
purpose: "Sends email to selected users, filtered users, or all users from the send-mail dialog.",
},
{
name: "user.dumpCsv",
method: "POST",
path: "{secure}/user/dumpCSV",
purpose: "Exports current selection/filter scope as CSV from the toolbar menu.",
},
{
name: "user.ban",
method: "POST",
path: "{secure}/user/ban",
purpose: "Batch-bans users by selected rows, current filtered scope, or all users.",
payloadExamples: [
{ scope: "selected", user_ids: [1, 2, 3] },
{ scope: "filtered", filter: [{ id: "email", value: "foo" }], sort: "id", sort_type: "DESC" },
{ scope: "all" },
],
},
{
name: "trafficReset.resetUser",
method: "POST",
path: "{secure}/traffic-reset/reset-user",
purpose: "Resets user traffic through the row action dialog.",
},
{
name: "order.assign",
method: "POST",
path: "{secure}/order/assign",
purpose: "Embedded assign-order flow reused from the order page.",
},
{
name: "plan.fetch",
method: "GET",
path: "{secure}/plan/fetch",
purpose: "Provides subscription plan options for filters, editing, generation, and assign-order.",
},
{
name: "server.group.fetch",
method: "GET",
path: "{secure}/server/group/fetch",
purpose: "Provides permission group options for toolbar and edit form.",
},
],
stateModel: {
list: {
rowSelection: {},
columnVisibility: {
is_admin: false,
is_staff: false,
},
columnFilters: [],
sorting: [],
pagination: {
pageIndex: 0,
pageSize: 20,
},
initialHiddenColumns: [
"commission_balance",
"created_at",
"is_admin",
"is_staff",
"permission_group",
"plan_id",
],
initialColumnPinning: {
right: ["actions"],
},
},
routeBootstrap: {
searchParamsToFilters: [{ key: "email", parser: "string" }],
},
toolbarDialogs: {
generateUsers: false,
sendMail: false,
batchBanConfirm: false,
advancedFilterSheet: false,
},
editSheet: {
open: false,
editingUser: null,
planOptions: [],
},
},
toolbarModel: {
primaryActions: [
"Generate users dialog",
"Email search filter on the email column",
"Advanced filter sheet",
"Column visibility menu",
"Bulk action dropdown",
],
advancedFilters: [
"email",
"id",
"plan_id",
"transfer_enable",
"total_used",
"online_count",
"expired_at",
"uuid",
"token",
"banned",
"remarks",
"invite_user.email",
"invite_user_id",
"is_admin",
"is_staff",
],
bulkMenu: [
"Send mail",
"Export CSV",
"Batch ban",
],
scopeResolution:
"Selection scope is selected rows when any row is checked, otherwise filtered scope when filters exist, otherwise all users.",
},
tableModel: {
columns: [
{
key: "select",
titleKey: "columns.select_row",
renderer: "Checkbox column with select-all checkbox in header.",
},
{
key: "is_admin",
titleKey: "columns.is_admin",
hiddenByDefault: true,
filterBehavior: "Facet filter based on boolean values.",
},
{
key: "is_staff",
titleKey: "columns.is_staff",
hiddenByDefault: true,
filterBehavior: "Facet filter based on boolean values.",
},
{
key: "id",
titleKey: "columns.id",
renderer: "Outline badge",
sortable: true,
},
{
key: "email",
titleKey: "columns.email",
renderer:
"Composite identity cell rendered by a dedicated user summary component, including email, UUID/token-related context, and online/offline indicators.",
},
{
key: "online_count",
titleKey: "columns.online_count",
renderer:
"Online devices badge showing current online_count against device_limit, with tooltip for unlimited vs limited semantics.",
sortable: true,
},
{
key: "banned",
titleKey: "columns.status",
renderer: "Status badge for banned vs normal.",
sortable: true,
},
{
key: "plan_id",
titleKey: "columns.subscription",
renderer: "Nested plan.name text",
},
{
key: "group_id",
titleKey: "columns.group",
renderer: "Nested group.name badge",
},
{
key: "total_used",
titleKey: "columns.used_traffic",
renderer:
"Usage progress bar with percentage and tooltip containing total traffic.",
},
{
key: "transfer_enable",
titleKey: "columns.total_traffic",
renderer: "Formatted total traffic text",
},
{
key: "expired_at",
titleKey: "columns.expire_time",
renderer:
"Status badge for permanent/remaining/expired with tooltip exposing full time, day delta, and next_reset_at when present.",
},
{
key: "balance",
titleKey: "columns.balance",
renderer: "Currency value",
},
{
key: "commission_balance",
titleKey: "columns.commission",
renderer: "Currency value",
},
{
key: "created_at",
titleKey: "columns.register_time",
renderer: "Registration time text",
},
{
key: "actions",
titleKey: "columns.actions",
renderer:
"Dropdown menu containing edit, assign order, copy subscribe URL, reset secret, orders link, invites filter shortcut, traffic records dialog, reset traffic dialog, and delete confirmation.",
},
],
mobileLayout: {
primaryField: "email",
gridFields: ["online_count", "transfer_enable", "used_traffic", "expired_at"],
},
},
dialogs: {
generateUsers: {
formFields: [
"email_prefix",
"email_suffix",
"password",
"expired_at",
"plan_id",
"generate_count",
"download_csv",
],
behavior: [
"If generate_count is empty, email_prefix becomes required.",
"If download_csv is enabled and generate_count is used, the response is handled as a Blob download.",
],
},
sendMail: {
scopes: ["selected", "filtered", "all"],
fields: ["subject", "content"],
templateSupport:
"Supports placeholder variables such as {{app.name}}, {{user.email}}, {{user.plan_name}}, etc.",
specialAction: "Apply system notice template button fills subject and content from built-in defaults.",
},
editUser: {
fields: [
"email",
"invite_user_email",
"password",
"balance",
"commission_balance",
"u",
"d",
"transfer_enable",
"expired_at",
"plan_id",
"banned",
"commission_type",
"commission_rate",
"discount",
"speed_limit",
"device_limit",
"is_admin",
"is_staff",
"remarks",
],
conversions: [
"u and d are displayed in GB with three decimals and converted back to bytes on submit.",
"transfer_enable is edited in GB and converted back to bytes on change.",
],
expireTimePresets: [
"permanent",
"1 month",
"3 months",
"specific datetime",
],
},
trafficRecords: {
columns: ["record_at", "u", "d", "server_rate", "total"],
purpose: "Shows paginated traffic usage history for a single user.",
},
},
interactions: [
"If the route opens with ?email=... the page seeds that value into the email column filter automatically.",
"Export CSV and batch ban both resolve their target scope from selected rows first, then filtered data, then all users.",
"Invites action does not navigate away; it rewrites current columnFilters to invite_user_id=eq:<current user id>.",
"Orders action deep-links into /finance/order?user_id=eq:<current user id>.",
"Copy URL action copies subscribe_url directly from the current row.",
"Reset secret, reset traffic, and delete all refetch the list after success.",
],
notes: [
"The page uses a provider to share edit-sheet state and refreshData across the table and side sheet.",
"Advanced filters are composed into table column filters, using operator prefixes such as eq:, gt:, lt:, and raw contains values.",
"Column visibility is user-controlled through a generic shared table view-options dropdown.",
],
sourceRefs: [
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:27389",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:300842",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:301426",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:303321",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:303835",
"frontend/admin/reverse/output/index-CO3BwsT2.pretty.js:304646",
],
});

View File

@@ -0,0 +1,526 @@
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
import {
compactText,
datetimeLocalToUnix,
formatBytes,
formatCurrencyFen,
formatUnixSeconds,
requestJson,
unixToDatetimeLocal,
} from "../../runtime/client.js";
const initialEditForm = {
id: "",
email: "",
password: "",
balance: "",
commission_balance: "",
commission_type: "",
commission_rate: "",
group_id: "",
plan_id: "",
speed_limit: "",
device_limit: "",
expired_at: "",
remarks: "",
};
const initialMailForm = {
user_id: "",
subject: "",
content: "",
};
function UserManagePage() {
const [loading, setLoading] = useState(true);
const [refreshTick, setRefreshTick] = useState(0);
const [searchInput, setSearchInput] = useState("");
const [keyword, setKeyword] = useState("");
const [rows, setRows] = useState([]);
const [pagination, setPagination] = useState({ current: 1, last_page: 1, per_page: 20, total: 0 });
const [plans, setPlans] = useState([]);
const [groups, setGroups] = useState([]);
const [modal, setModal] = useState(null);
const [editForm, setEditForm] = useState(initialEditForm);
const [mailForm, setMailForm] = useState(initialMailForm);
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
(async () => {
try {
const [planResult, groupResult] = await Promise.all([
requestJson("/plan/fetch"),
requestJson("/server/group/fetch"),
]);
if (!active) {
return;
}
setPlans(Array.isArray(planResult?.data) ? planResult.data : planResult?.list || planResult || []);
setGroups(Array.isArray(groupResult?.data) ? groupResult.data : groupResult?.list || groupResult || []);
} catch (err) {
if (active) {
setNotice(err.message || "Reference data failed to load");
}
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
setError("");
try {
const query = new URLSearchParams({
page: String(pagination.current || 1),
per_page: String(pagination.per_page || 20),
});
if (keyword) {
query.set("keyword", keyword);
}
const payload = await requestJson(`/user/fetch?${query.toString()}`);
if (!active) {
return;
}
setRows(Array.isArray(payload?.list) ? payload.list : []);
setPagination(payload?.pagination || pagination);
} catch (err) {
if (active) {
setError(err.message || "Failed to load users");
setRows([]);
}
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
}, [keyword, pagination.current, pagination.per_page, refreshTick]);
const openEditModal = (user) => {
setEditForm({
id: user.id,
email: user.email || "",
password: "",
balance: user.balance ?? "",
commission_balance: user.commission_balance ?? "",
commission_type: user.commission_type ?? "",
commission_rate: user.commission_rate ?? "",
group_id: user.group_id ?? "",
plan_id: user.plan_id ?? "",
speed_limit: user.speed_limit ?? "",
device_limit: user.device_limit ?? "",
expired_at: unixToDatetimeLocal(user.expired_at),
remarks: user.remarks || "",
});
setModal({ type: "edit", user });
};
const openMailModal = (user) => {
setMailForm({
user_id: user.id,
subject: `Hello ${user.email}`,
content: "",
});
setModal({ type: "mail", user });
};
const submitSearch = (event) => {
event.preventDefault();
setPagination((current) => ({ ...current, current: 1 }));
setKeyword(compactText(searchInput));
};
const reload = () => setRefreshTick((value) => value + 1);
const saveUser = async (event) => {
event.preventDefault();
setNotice("");
setError("");
try {
const payload = {
id: Number(editForm.id),
email: compactText(editForm.email),
password: compactText(editForm.password) || undefined,
balance: toNullableNumber(editForm.balance),
commission_balance: toNullableNumber(editForm.commission_balance),
commission_type: toNullableNumber(editForm.commission_type),
commission_rate: toNullableNumber(editForm.commission_rate),
group_id: toNullableNumber(editForm.group_id),
plan_id: toNullableNumber(editForm.plan_id),
speed_limit: toNullableNumber(editForm.speed_limit),
device_limit: toNullableNumber(editForm.device_limit),
expired_at: datetimeLocalToUnix(editForm.expired_at),
remarks: compactText(editForm.remarks),
};
pruneUndefined(payload);
await requestJson("/user/update", { method: "POST", body: payload });
setModal(null);
reload();
} catch (err) {
setError(err.message || "Failed to save user");
}
};
const resetSecret = async (user) => {
if (!window.confirm(`Reset secret for ${user.email}?`)) {
return;
}
try {
const payload = await requestJson("/user/resetSecret", {
method: "POST",
body: { id: user.id },
});
setNotice(typeof payload?.data === "string" ? `Secret reset: ${payload.data}` : "Secret reset");
reload();
} catch (err) {
setError(err.message || "Failed to reset secret");
}
};
const resetTraffic = async (user) => {
if (!window.confirm(`Reset traffic for ${user.email}?`)) {
return;
}
try {
await requestJson("/user/resetTraffic", {
method: "POST",
body: { id: user.id },
});
setNotice("Traffic reset complete");
reload();
} catch (err) {
setError(err.message || "Failed to reset traffic");
}
};
const toggleBan = async (user) => {
const banned = !Boolean(user.banned);
if (!window.confirm(`${banned ? "Ban" : "Unban"} ${user.email}?`)) {
return;
}
try {
await requestJson("/user/ban", {
method: "POST",
body: { id: user.id, banned },
});
reload();
} catch (err) {
setError(err.message || "Failed to update status");
}
};
const deleteUser = async (user) => {
if (!window.confirm(`Delete ${user.email}?`)) {
return;
}
try {
await requestJson("/user/drop", {
method: "POST",
body: { id: user.id },
});
reload();
} catch (err) {
setError(err.message || "Failed to delete user");
}
};
const saveMail = async (event) => {
event.preventDefault();
try {
await requestJson("/user/sendMail", {
method: "POST",
body: {
user_id: Number(mailForm.user_id),
subject: compactText(mailForm.subject),
content: compactText(mailForm.content),
},
});
setModal(null);
setNotice("Mail submitted");
} catch (err) {
setError(err.message || "Failed to send mail");
}
};
return (
<div className="recovery-live-page">
<header className="recovery-live-hero">
<div>
<p className="eyebrow">Recovered React Page</p>
<h2>User Management</h2>
<p>Search, edit, ban, reset traffic, and reset secrets against the live admin API.</p>
</div>
<div className="hero-stats">
<div><strong>{pagination.total}</strong><span>users</span></div>
<div><strong>{pagination.current}</strong><span>page</span></div>
<div><strong>{plans.length}</strong><span>plans</span></div>
</div>
</header>
<section className="recovery-toolbar">
<form className="recovery-search" onSubmit={submitSearch}>
<input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Search by email or ID"
/>
<button type="submit">Search</button>
</form>
<button
type="button"
className="secondary"
onClick={() => {
setSearchInput("");
setKeyword("");
setPagination((current) => ({ ...current, current: 1 }));
}}
>
Clear
</button>
</section>
{notice ? <div className="toast toast-info">{notice}</div> : null}
{error ? <div className="toast toast-error">{error}</div> : null}
<section className="recovery-table-card">
<div className="table-scroll">
<table className="recovery-table">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Plan / Group</th>
<th>Traffic</th>
<th>Balance</th>
<th>Status</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="empty-cell">Loading...</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={8} className="empty-cell">No users found</td></tr>
) : rows.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>
<div className="cell-stack">
<strong>{user.email}</strong>
<span className="muted">UID: {user.id}</span>
{user.online_ip ? <span className="chip chip-soft">{user.online_ip}</span> : null}
</div>
</td>
<td>
<div className="cell-stack">
<span>{user.plan_name || `Plan ${user.plan_id || "-"}`}</span>
<span className="muted">{user.group_name || `Group ${user.group_id || "-"}`}</span>
</div>
</td>
<td>
<div className="cell-stack">
<span>{formatBytes(user.transfer_enable)}</span>
<span className="muted">Used {formatBytes(Number(user.u || 0) + Number(user.d || 0))}</span>
</div>
</td>
<td>
<div className="cell-stack">
<span>{formatCurrencyFen(user.balance)}</span>
<span className="muted">Comm {formatCurrencyFen(user.commission_balance)}</span>
</div>
</td>
<td>
<span className={`chip ${user.banned ? "chip-danger" : "chip-ok"}`}>
{user.banned ? "Banned" : "Active"}
</span>
</td>
<td>{formatUnixSeconds(user.updated_at || user.last_login_at || user.created_at)}</td>
<td>
<div className="action-row">
<button type="button" onClick={() => openEditModal(user)}>Edit</button>
<button type="button" onClick={() => openMailModal(user)}>Mail</button>
<button type="button" onClick={() => resetSecret(user)}>Secret</button>
<button type="button" onClick={() => resetTraffic(user)}>Traffic</button>
<button type="button" onClick={() => toggleBan(user)}>
{user.banned ? "Unban" : "Ban"}
</button>
<button type="button" className="danger" onClick={() => deleteUser(user)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination-bar">
<button
type="button"
disabled={pagination.current <= 1}
onClick={() => setPagination((current) => ({ ...current, current: Math.max(1, current.current - 1) }))}
>
Prev
</button>
<span>
Page {pagination.current} / {pagination.last_page}
</span>
<button
type="button"
disabled={pagination.current >= pagination.last_page}
onClick={() => setPagination((current) => ({ ...current, current: Math.min(current.last_page, current.current + 1) }))}
>
Next
</button>
</div>
</section>
{modal?.type === "edit" ? (
<Modal title={`Edit user ${editForm.email}`} onClose={() => setModal(null)}>
<form className="modal-grid" onSubmit={saveUser}>
<label>
<span>ID</span>
<input value={editForm.id} readOnly />
</label>
<label>
<span>Email</span>
<input value={editForm.email} onChange={(event) => setEditForm((current) => ({ ...current, email: event.target.value }))} />
</label>
<label>
<span>Password</span>
<input
type="password"
value={editForm.password}
onChange={(event) => setEditForm((current) => ({ ...current, password: event.target.value }))}
placeholder="leave blank to keep"
/>
</label>
<label>
<span>Balance</span>
<input type="number" value={editForm.balance} onChange={(event) => setEditForm((current) => ({ ...current, balance: event.target.value }))} />
</label>
<label>
<span>Commission Balance</span>
<input type="number" value={editForm.commission_balance} onChange={(event) => setEditForm((current) => ({ ...current, commission_balance: event.target.value }))} />
</label>
<label>
<span>Commission Type</span>
<input type="number" value={editForm.commission_type} onChange={(event) => setEditForm((current) => ({ ...current, commission_type: event.target.value }))} />
</label>
<label>
<span>Commission Rate</span>
<input type="number" value={editForm.commission_rate} onChange={(event) => setEditForm((current) => ({ ...current, commission_rate: event.target.value }))} />
</label>
<label>
<span>Group</span>
<select value={editForm.group_id} onChange={(event) => setEditForm((current) => ({ ...current, group_id: event.target.value }))}>
<option value="">-</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name || `Group ${group.id}`}</option>
))}
</select>
</label>
<label>
<span>Plan</span>
<select value={editForm.plan_id} onChange={(event) => setEditForm((current) => ({ ...current, plan_id: event.target.value }))}>
<option value="">-</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>{plan.name || `Plan ${plan.id}`}</option>
))}
</select>
</label>
<label>
<span>Speed Limit</span>
<input type="number" value={editForm.speed_limit} onChange={(event) => setEditForm((current) => ({ ...current, speed_limit: event.target.value }))} />
</label>
<label>
<span>Device Limit</span>
<input type="number" value={editForm.device_limit} onChange={(event) => setEditForm((current) => ({ ...current, device_limit: event.target.value }))} />
</label>
<label>
<span>Expired At</span>
<input
type="datetime-local"
value={editForm.expired_at}
onChange={(event) => setEditForm((current) => ({ ...current, expired_at: event.target.value }))}
/>
</label>
<label className="span-2">
<span>Remarks</span>
<textarea value={editForm.remarks} onChange={(event) => setEditForm((current) => ({ ...current, remarks: event.target.value }))} />
</label>
<div className="modal-actions span-2">
<button type="button" className="secondary" onClick={() => setModal(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</Modal>
) : null}
{modal?.type === "mail" ? (
<Modal title={`Send mail to ${mailForm.user_id}`} onClose={() => setModal(null)}>
<form className="modal-grid" onSubmit={saveMail}>
<label>
<span>User ID</span>
<input value={mailForm.user_id} readOnly />
</label>
<label className="span-2">
<span>Subject</span>
<input value={mailForm.subject} onChange={(event) => setMailForm((current) => ({ ...current, subject: event.target.value }))} />
</label>
<label className="span-2">
<span>Content</span>
<textarea rows={8} value={mailForm.content} onChange={(event) => setMailForm((current) => ({ ...current, content: event.target.value }))} />
</label>
<div className="modal-actions span-2">
<button type="button" className="secondary" onClick={() => setModal(null)}>Cancel</button>
<button type="submit">Send</button>
</div>
</form>
</Modal>
) : null}
</div>
);
}
function Modal({ title, children, onClose }) {
return (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal-panel" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<header className="modal-header">
<h3>{title}</h3>
<button type="button" className="secondary" onClick={onClose}>Close</button>
</header>
{children}
</div>
</div>
);
}
function pruneUndefined(target) {
Object.keys(target).forEach((key) => {
if (target[key] === undefined || target[key] === null || target[key] === "") {
delete target[key];
}
});
}
function toNullableNumber(value) {
const trimmed = compactText(value);
if (!trimmed) {
return undefined;
}
const number = Number(trimmed);
return Number.isNaN(number) ? undefined : number;
}
export default UserManagePage;

View File

@@ -0,0 +1,9 @@
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
export default makeSkeletonPage({
title: "User Ticket",
routePath: "/user/ticket",
moduleId: "A8t",
featureKey: "user.ticket",
notes: ["Contains ticket list, sidebar detail, reply, close, and related order jump flows."],
});

View File

@@ -0,0 +1,26 @@
const TOKEN_KEY = "__xboard_admin_token__";
export function getAuthToken() {
return window.localStorage.getItem(TOKEN_KEY) || "";
}
export function setAuthToken(token) {
if (!token) {
window.localStorage.removeItem(TOKEN_KEY);
return;
}
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearAuthToken() {
window.localStorage.removeItem(TOKEN_KEY);
}
export function getLanguage() {
return window.localStorage.getItem("i18nextLng") || "zh-CN";
}
export function isPublicEndpoint(pathname, publicEndpoints) {
return publicEndpoints.includes(pathname);
}

View File

@@ -0,0 +1,108 @@
import { buildRequestDescriptor, handleHttpError } from "./http.js";
export async function requestJson(pathname, options = {}) {
const descriptor = buildRequestDescriptor(options.method || "GET", pathname, options);
const init = {
method: descriptor.method,
headers: descriptor.headers,
credentials: "same-origin",
};
if (options.body != null) {
init.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
}
const response = await fetch(descriptor.url, init);
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok) {
handleHttpError(response.status);
const message = payload && (payload.message || payload.error) ? (payload.message || payload.error) : "Request failed";
const err = new Error(message);
err.status = response.status;
err.payload = payload;
throw err;
}
return payload;
}
export function normalizePagination(payload) {
const pagination = payload && payload.pagination ? payload.pagination : {};
return {
current: Number(pagination.current || 1),
last_page: Number(pagination.last_page || 1),
per_page: Number(pagination.per_page || 20),
total: Number(pagination.total || 0),
};
}
export function formatCurrencyFen(value) {
const amount = Number(value || 0) / 100;
return `¥${amount.toFixed(2)}`;
}
export function formatBytes(value) {
let size = Number(value || 0);
const units = ["B", "KB", "MB", "GB", "TB"];
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(size >= 100 || index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatUnixSeconds(value) {
const seconds = Number(value || 0);
if (!seconds) {
return "-";
}
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleString();
}
export function unixToDatetimeLocal(value) {
const seconds = Number(value || 0);
if (!seconds) {
return "";
}
const date = new Date(seconds * 1000);
const pad = (num) => String(num).padStart(2, "0");
return [
date.getFullYear(),
"-",
pad(date.getMonth() + 1),
"-",
pad(date.getDate()),
"T",
pad(date.getHours()),
":",
pad(date.getMinutes()),
].join("");
}
export function datetimeLocalToUnix(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return Math.floor(date.getTime() / 1000);
}
export function compactText(value) {
return String(value == null ? "" : value).trim();
}

View File

@@ -0,0 +1,59 @@
import { clearAuthToken, getAuthToken, getLanguage, isPublicEndpoint } from "./auth.js";
import { getApiBaseUrl, getSecurePath } from "./settings.js";
export const publicEndpoints = [
"/passport/auth/login",
"/passport/auth/token2Login",
"/passport/auth/register",
"/guest/comm/config",
"/passport/comm/sendEmailVerify",
"/passport/auth/forget",
];
export function buildAdminPath(pathname) {
const securePath = String(getSecurePath() || "").replace(/^\/+/, "");
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${securePath}${normalized}`;
}
function resolveApiUrl(pathname, isPublic) {
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (normalized.startsWith("http")) {
return normalized;
}
if (isPublic) {
return `${getApiBaseUrl()}${normalized}`;
}
const securePath = String(getSecurePath() || "").replace(/^\/+/, "");
return `${getApiBaseUrl()}/${securePath}${normalized}`;
}
export function buildRequestDescriptor(method, pathname, options = {}) {
const token = getAuthToken();
const apiPath = pathname.split("?")[0];
const headers = {
"Content-Type": "application/json",
"Content-Language": getLanguage(),
...(options.headers || {}),
};
if (!isPublicEndpoint(apiPath, publicEndpoints) && token) {
headers.Authorization = token;
}
return {
method,
url: resolveApiUrl(pathname, isPublicEndpoint(apiPath, publicEndpoints)),
headers,
body: options.body,
securePath: getSecurePath(),
};
}
export function handleHttpError(statusCode) {
if (statusCode === 401 || statusCode === 403) {
clearAuthToken();
}
}

View File

@@ -0,0 +1,14 @@
export const i18nRuntimeConfig = {
resources: ["en-US", "zh-CN", "ko-KR", "ru-RU"],
fallbackLanguage: "zh-CN",
detection: {
order: ["querystring", "localStorage", "navigator"],
lookupQuerystring: "lang",
lookupLocalStorage: "i18nextLng",
caches: ["localStorage"],
},
};
export function getLoadedTranslationNamespaces() {
return Object.keys(window.XBOARD_TRANSLATIONS || {});
}

View File

@@ -0,0 +1,33 @@
export function makeRecoveredPage(definition) {
const {
title,
routePath,
moduleId,
featureKey,
component = null,
status = "recovered",
...recovery
} = definition;
return {
title,
routePath,
moduleId,
featureKey,
component,
status,
...recovery,
render() {
return {
type: "recovered-page",
title,
routePath,
moduleId,
featureKey,
component,
status,
recovery,
};
},
};
}

View File

@@ -0,0 +1,16 @@
export function makeSkeletonPage(definition) {
return {
...definition,
status: definition.status || "skeleton",
render() {
return {
type: "skeleton-page",
title: definition.title,
routePath: definition.routePath,
moduleId: definition.moduleId,
featureKey: definition.featureKey,
notes: definition.notes || [],
};
},
};
}

View File

@@ -0,0 +1,33 @@
function normalizeSlashes(value) {
return String(value || "").replace(/\/+$/, "");
}
export function getWindowSettings() {
return window.settings || {};
}
export function getBaseUrl() {
const baseUrl = getWindowSettings().base_url || "/";
return normalizeSlashes(baseUrl) || "";
}
export function getApiBaseUrl() {
const baseUrl = getBaseUrl();
return baseUrl ? `${baseUrl}/api/v2` : "/api/v2";
}
export function getSecurePath() {
return getWindowSettings().secure_path || "";
}
export function getAppMeta() {
const settings = getWindowSettings();
return {
title: settings.title || "Admin",
version: settings.version || "",
logo: settings.logo || "",
baseUrl: getBaseUrl(),
apiBaseUrl: getApiBaseUrl(),
securePath: getSecurePath(),
};
}