527 lines
18 KiB
JavaScript
527 lines
18 KiB
JavaScript
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;
|