Files
SingBox-Gopanel/frontend/admin/src-reverse/pages/finance/FinanceOrderPage.jsx
CN-JS-HuiBai 25fd919477
All checks were successful
build / build (api, amd64, linux) (push) Successful in -43s
build / build (api, arm64, linux) (push) Successful in -44s
build / build (api.exe, amd64, windows) (push) Successful in -43s
API功能性修复
2026-04-17 15:13:43 +08:00

422 lines
14 KiB
JavaScript

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;