修复丢失的前端文件
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user