修复丢失的前端文件
This commit is contained in:
59
frontend/admin/reverse/README.md
Normal file
59
frontend/admin/reverse/README.md
Normal 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
|
||||
```
|
||||
148
frontend/admin/reverse/extracted/api-manifest.js
Normal file
148
frontend/admin/reverse/extracted/api-manifest.js
Normal 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,
|
||||
};
|
||||
32
frontend/admin/reverse/extracted/navigation-manifest.js
Normal file
32
frontend/admin/reverse/extracted/navigation-manifest.js
Normal 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,
|
||||
};
|
||||
77
frontend/admin/reverse/extracted/router-manifest.js
Normal file
77
frontend/admin/reverse/extracted/router-manifest.js
Normal 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,
|
||||
};
|
||||
42
frontend/admin/reverse/extracted/runtime-manifest.js
Normal file
42
frontend/admin/reverse/extracted/runtime-manifest.js
Normal 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,
|
||||
};
|
||||
4437
frontend/admin/reverse/output/bundle-report.json
Normal file
4437
frontend/admin/reverse/output/bundle-report.json
Normal file
File diff suppressed because it is too large
Load Diff
308234
frontend/admin/reverse/output/index-CO3BwsT2.pretty.js
Normal file
308234
frontend/admin/reverse/output/index-CO3BwsT2.pretty.js
Normal file
File diff suppressed because one or more lines are too long
12113
frontend/admin/reverse/output/index-DTPKq_WI.pretty.css
Normal file
12113
frontend/admin/reverse/output/index-DTPKq_WI.pretty.css
Normal file
File diff suppressed because one or more lines are too long
3318
frontend/admin/reverse/output/locales/en-US.pretty.js
Normal file
3318
frontend/admin/reverse/output/locales/en-US.pretty.js
Normal file
File diff suppressed because it is too large
Load Diff
3178
frontend/admin/reverse/output/locales/ru-RU.pretty.js
Normal file
3178
frontend/admin/reverse/output/locales/ru-RU.pretty.js
Normal file
File diff suppressed because it is too large
Load Diff
3246
frontend/admin/reverse/output/locales/zh-CN.pretty.js
Normal file
3246
frontend/admin/reverse/output/locales/zh-CN.pretty.js
Normal file
File diff suppressed because it is too large
Load Diff
232
frontend/admin/reverse/package-lock.json
generated
Normal file
232
frontend/admin/reverse/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/admin/reverse/package.json
Normal file
16
frontend/admin/reverse/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
55
frontend/admin/reverse/scripts/beautify-bundle.cjs
Normal file
55
frontend/admin/reverse/scripts/beautify-bundle.cjs
Normal 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;
|
||||
});
|
||||
95
frontend/admin/reverse/scripts/inspect-bundle.cjs
Normal file
95
frontend/admin/reverse/scripts/inspect-bundle.cjs
Normal 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")}`);
|
||||
25
frontend/admin/src-reverse/README.md
Normal file
25
frontend/admin/src-reverse/README.md
Normal 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.
|
||||
59
frontend/admin/src-reverse/config/apiModules.js
Normal file
59
frontend/admin/src-reverse/config/apiModules.js
Normal 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",
|
||||
],
|
||||
};
|
||||
40
frontend/admin/src-reverse/config/navigation.js
Normal file
40
frontend/admin/src-reverse/config/navigation.js
Normal 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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
58
frontend/admin/src-reverse/config/routes.js
Normal file
58
frontend/admin/src-reverse/config/routes.js
Normal 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 },
|
||||
];
|
||||
4
frontend/admin/src-reverse/index.js
Normal file
4
frontend/admin/src-reverse/index.js
Normal 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";
|
||||
393
frontend/admin/src-reverse/pages/DashboardOverviewPage.js
Normal file
393
frontend/admin/src-reverse/pages/DashboardOverviewPage.js
Normal 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",
|
||||
],
|
||||
});
|
||||
9
frontend/admin/src-reverse/pages/SignInPage.js
Normal file
9
frontend/admin/src-reverse/pages/SignInPage.js
Normal 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."],
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
10
frontend/admin/src-reverse/pages/config/ThemeConfigPage.js
Normal file
10
frontend/admin/src-reverse/pages/config/ThemeConfigPage.js
Normal 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,
|
||||
});
|
||||
284
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal file
284
frontend/admin/src-reverse/pages/config/ThemeConfigPage.jsx
Normal 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;
|
||||
@@ -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."],
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
8
frontend/admin/src-reverse/pages/errors/Error404Page.js
Normal file
8
frontend/admin/src-reverse/pages/errors/Error404Page.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Error 404",
|
||||
routePath: "/404",
|
||||
moduleId: "Dm",
|
||||
featureKey: "error.404",
|
||||
});
|
||||
8
frontend/admin/src-reverse/pages/errors/Error500Page.js
Normal file
8
frontend/admin/src-reverse/pages/errors/Error500Page.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Error 500",
|
||||
routePath: "/500",
|
||||
moduleId: "Lm",
|
||||
featureKey: "error.500",
|
||||
});
|
||||
9
frontend/admin/src-reverse/pages/errors/Error503Page.js
Normal file
9
frontend/admin/src-reverse/pages/errors/Error503Page.js
Normal 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."],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Finance Coupon",
|
||||
routePath: "/finance/coupon",
|
||||
moduleId: "z3t",
|
||||
featureKey: "finance.coupon",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
322
frontend/admin/src-reverse/pages/finance/FinanceOrderPage.js
Normal file
322
frontend/admin/src-reverse/pages/finance/FinanceOrderPage.js
Normal 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",
|
||||
],
|
||||
});
|
||||
421
frontend/admin/src-reverse/pages/finance/FinanceOrderPage.jsx
Normal file
421
frontend/admin/src-reverse/pages/finance/FinanceOrderPage.jsx
Normal 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;
|
||||
312
frontend/admin/src-reverse/pages/finance/FinancePlanPage.js
Normal file
312
frontend/admin/src-reverse/pages/finance/FinancePlanPage.js
Normal 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",
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Server Group",
|
||||
routePath: "/server/group",
|
||||
moduleId: "G5t",
|
||||
featureKey: "server.group",
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Server Route",
|
||||
routePath: "/server/route",
|
||||
moduleId: "e3t",
|
||||
featureKey: "server.route",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
404
frontend/admin/src-reverse/pages/user/UserManagePage.js
Normal file
404
frontend/admin/src-reverse/pages/user/UserManagePage.js
Normal 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",
|
||||
],
|
||||
});
|
||||
526
frontend/admin/src-reverse/pages/user/UserManagePage.jsx
Normal file
526
frontend/admin/src-reverse/pages/user/UserManagePage.jsx
Normal 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;
|
||||
9
frontend/admin/src-reverse/pages/user/UserTicketPage.js
Normal file
9
frontend/admin/src-reverse/pages/user/UserTicketPage.js
Normal 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."],
|
||||
});
|
||||
26
frontend/admin/src-reverse/runtime/auth.js
Normal file
26
frontend/admin/src-reverse/runtime/auth.js
Normal 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);
|
||||
}
|
||||
108
frontend/admin/src-reverse/runtime/client.js
Normal file
108
frontend/admin/src-reverse/runtime/client.js
Normal 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();
|
||||
}
|
||||
59
frontend/admin/src-reverse/runtime/http.js
Normal file
59
frontend/admin/src-reverse/runtime/http.js
Normal 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();
|
||||
}
|
||||
}
|
||||
14
frontend/admin/src-reverse/runtime/i18n.js
Normal file
14
frontend/admin/src-reverse/runtime/i18n.js
Normal 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 || {});
|
||||
}
|
||||
33
frontend/admin/src-reverse/runtime/makeRecoveredPage.js
Normal file
33
frontend/admin/src-reverse/runtime/makeRecoveredPage.js
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
16
frontend/admin/src-reverse/runtime/makeSkeletonPage.js
Normal file
16
frontend/admin/src-reverse/runtime/makeSkeletonPage.js
Normal 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 || [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
33
frontend/admin/src-reverse/runtime/settings.js
Normal file
33
frontend/admin/src-reverse/runtime/settings.js
Normal 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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user