API功能性修复
This commit is contained in:
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."],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { makeSkeletonPage } from "../../runtime/makeSkeletonPage.js";
|
||||
|
||||
export default makeSkeletonPage({
|
||||
title: "Theme Config",
|
||||
routePath: "/config/theme",
|
||||
moduleId: "WQt",
|
||||
featureKey: "config.theme",
|
||||
notes: ["Bundle contains theme activation and theme config editing flows."],
|
||||
});
|
||||
@@ -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."],
|
||||
});
|
||||
Reference in New Issue
Block a user