511 lines
17 KiB
JavaScript
511 lines
17 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
|
import { downloadResponse } from "@/shared/api/download";
|
|
import { parseForm } from "@/shared/forms/validation";
|
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
|
import {
|
|
adjustAppUserLevels,
|
|
banAppUser,
|
|
createAppUserExport,
|
|
downloadAppUserExport,
|
|
getAppUserExportJob,
|
|
listAppUsers,
|
|
setAppUserPassword,
|
|
unbanAppUser,
|
|
updateAppUser,
|
|
} from "@/features/app-users/api";
|
|
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
|
import {
|
|
appUserBanSchema,
|
|
appUserCountrySchema,
|
|
appUserLevelAdjustmentSchema,
|
|
appUserPasswordSchema,
|
|
appUserUpdateSchema,
|
|
} from "@/features/app-users/schema";
|
|
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
|
|
|
const pageSize = 50;
|
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
|
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
|
|
const emptyCountryForm = () => ({ country: "" });
|
|
const emptyPasswordForm = () => ({ password: "" });
|
|
const emptyBanForm = () => ({ expiresAtLocal: futureDateTimeLocal(7), mode: "permanent", reason: "" });
|
|
|
|
export function useAppUsersPage() {
|
|
const abilities = useAppUserAbilities();
|
|
const confirm = useConfirm();
|
|
const { showToast } = useToast();
|
|
const { countryOptions, loadingCountries } = useCountryOptions();
|
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
|
const [country, setCountry] = useState("");
|
|
const [userFilter, setUserFilter] = useState(emptyUserIdentityFilter);
|
|
const [regionId, setRegionId] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
|
const [sortBy, setSortBy] = useState("created_at");
|
|
const [sortDirection, setSortDirection] = useState("desc");
|
|
const [page, setPage] = useState(1);
|
|
const [activeAction, setActiveAction] = useState("");
|
|
const [activeUser, setActiveUser] = useState(null);
|
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
|
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
|
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
|
const [levelForm, setLevelForm] = useState(() => levelFormFromUser(null));
|
|
const [banForm, setBanForm] = useState(emptyBanForm);
|
|
const [loadingAction, setLoadingAction] = useState("");
|
|
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
|
const [patchedUsers, setPatchedUsers] = useState({});
|
|
const filters = useMemo(
|
|
() => ({
|
|
country,
|
|
region_id: regionId,
|
|
sort_by: sortBy,
|
|
sort_direction: sortDirection,
|
|
end_ms: timeRange.endMs,
|
|
status,
|
|
start_ms: timeRange.startMs,
|
|
...userIdentityFilterParams(userFilter),
|
|
}),
|
|
[country, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs, userFilter],
|
|
);
|
|
const {
|
|
data: queryData = emptyData,
|
|
error,
|
|
loading,
|
|
reload,
|
|
} = usePaginatedQuery({
|
|
errorMessage: "加载用户列表失败",
|
|
fetcher: listAppUsers,
|
|
filters,
|
|
page,
|
|
pageSize,
|
|
queryKey: ["app-users", filters, page],
|
|
});
|
|
const data = useMemo(() => {
|
|
const items = queryData.items || [];
|
|
return {
|
|
...queryData,
|
|
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
|
|
};
|
|
}, [patchedUsers, queryData]);
|
|
useEffect(() => {
|
|
setSelectedUserIds([]);
|
|
setPatchedUsers({});
|
|
}, [country, page, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs, userFilter]);
|
|
|
|
const locationFilterOptions = useMemo(() => {
|
|
const countryItems = countryOptions.map((option) => ({
|
|
label: `国家 · ${option.label}`,
|
|
value: `country:${option.value}`,
|
|
}));
|
|
const regionItems = [
|
|
{ label: "区域 · GLOBAL · 0", value: "region:0" },
|
|
...regionOptions.map((option) => ({
|
|
label: `区域 · ${option.label}`,
|
|
value: `region:${option.value}`,
|
|
})),
|
|
];
|
|
return [...countryItems, ...regionItems];
|
|
}, [countryOptions, regionOptions]);
|
|
|
|
const locationFilterValue = country ? `country:${country}` : regionId !== "" ? `region:${regionId}` : "";
|
|
|
|
const changeUserFilter = (value) => {
|
|
setUserFilter(value);
|
|
setPage(1);
|
|
};
|
|
|
|
const changeStatus = (value) => {
|
|
setStatus(value);
|
|
setPage(1);
|
|
};
|
|
|
|
const changeLocationFilter = (value) => {
|
|
const [kind, rawValue = ""] = String(value || "").split(":");
|
|
if (kind === "country" && rawValue) {
|
|
setCountry(rawValue);
|
|
setRegionId("");
|
|
} else if (kind === "region") {
|
|
setCountry("");
|
|
setRegionId(rawValue);
|
|
} else {
|
|
setCountry("");
|
|
setRegionId("");
|
|
}
|
|
setPage(1);
|
|
};
|
|
|
|
const changeTimeRange = (value) => {
|
|
setTimeRange(value);
|
|
setPage(1);
|
|
};
|
|
|
|
const changeSort = (field) => {
|
|
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
|
setSortBy(field);
|
|
setPage(1);
|
|
};
|
|
|
|
const resetFilters = () => {
|
|
setCountry("");
|
|
setUserFilter(emptyUserIdentityFilter);
|
|
setRegionId("");
|
|
setStatus("");
|
|
setTimeRange({ endMs: "", startMs: "" });
|
|
setSortBy("created_at");
|
|
setSortDirection("desc");
|
|
setPage(1);
|
|
};
|
|
|
|
const openEdit = (user) => {
|
|
setActiveUser(user);
|
|
setEditForm({
|
|
avatar: user.avatar || "",
|
|
gender: user.gender || "",
|
|
username: user.username || "",
|
|
});
|
|
setLevelForm(levelFormFromUser(user));
|
|
setActiveAction("edit");
|
|
};
|
|
|
|
const openPassword = (user) => {
|
|
setActiveUser(user);
|
|
setPasswordForm(emptyPasswordForm());
|
|
setActiveAction("password");
|
|
};
|
|
|
|
const openCountry = (user) => {
|
|
setActiveUser(user);
|
|
setCountryForm({ country: user.country || "" });
|
|
setActiveAction("country");
|
|
};
|
|
|
|
const openCoinLedger = (user) => {
|
|
setActiveUser(user);
|
|
setActiveAction("coin-ledger");
|
|
};
|
|
|
|
const closeAction = () => {
|
|
setActiveAction("");
|
|
setActiveUser(null);
|
|
};
|
|
|
|
const submitEdit = async (event) => {
|
|
event.preventDefault();
|
|
if (!activeUser) {
|
|
return;
|
|
}
|
|
await runAction("edit", "用户已更新", async () => {
|
|
const payload = parseForm(appUserUpdateSchema, editForm);
|
|
const result = await updateAppUser(activeUser.userId, payload);
|
|
patchUsers([result]);
|
|
setActiveUser((current) => (current ? { ...current, ...result } : result));
|
|
});
|
|
};
|
|
|
|
const submitLevels = async () => {
|
|
if (!activeUser) {
|
|
return;
|
|
}
|
|
await runAction("levels", "等级调整已提交", async () => {
|
|
const form = parseForm(appUserLevelAdjustmentSchema, levelForm);
|
|
// 两条轨道只发一个 adjustments 数组,保证跨轨道由 activity-service 同事务提交或整体失败。
|
|
const adjustments = ["wealth", "charm"]
|
|
.filter((track) => form[track].enabled)
|
|
.map((track) => ({
|
|
duration_days: Number(form[track].durationDays),
|
|
level: Number(form[track].level),
|
|
track,
|
|
}));
|
|
const result = await adjustAppUserLevels(activeUser.userId, { adjustments, reason: form.reason });
|
|
patchUsers([result]);
|
|
setActiveUser((current) => (current ? { ...current, ...result } : result));
|
|
setLevelForm(levelFormFromUser(result));
|
|
});
|
|
};
|
|
|
|
const submitCountry = async (event) => {
|
|
event.preventDefault();
|
|
if (!activeUser) {
|
|
return;
|
|
}
|
|
await runAction("country", "国家已更新", async () => {
|
|
const payload = parseForm(appUserCountrySchema, countryForm);
|
|
await updateAppUser(activeUser.userId, payload);
|
|
closeAction();
|
|
await reload();
|
|
});
|
|
};
|
|
|
|
const submitPassword = async (event) => {
|
|
event.preventDefault();
|
|
if (!activeUser) {
|
|
return;
|
|
}
|
|
await runAction("password", "密码已设置", async () => {
|
|
const payload = parseForm(appUserPasswordSchema, passwordForm);
|
|
await setAppUserPassword(activeUser.userId, payload);
|
|
closeAction();
|
|
});
|
|
};
|
|
|
|
const toggleBan = async (user) => {
|
|
const banned = isBanned(user);
|
|
if (!banned) {
|
|
setActiveUser(user);
|
|
setBanForm(emptyBanForm());
|
|
setActiveAction("ban");
|
|
return;
|
|
}
|
|
const ok = await confirm({
|
|
confirmText: "解封",
|
|
message: "仅当该用户不存在其他有效管理员或经理封禁时,账号状态才会恢复。",
|
|
title: "解封用户",
|
|
});
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
await runAction(`status-${user.userId}`, "用户已解封", async () => {
|
|
const result = await unbanAppUser(user.userId, { reason: "admin_manual_unban" });
|
|
patchUsers([mergeUserStatus(user, result, "active")]);
|
|
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
|
|
});
|
|
};
|
|
|
|
const submitBan = async (event) => {
|
|
event.preventDefault();
|
|
if (!activeUser) {
|
|
return;
|
|
}
|
|
await runAction(`status-${activeUser.userId}`, "用户已封禁", async () => {
|
|
const form = parseForm(appUserBanSchema, banForm);
|
|
const expiresAtMs = form.mode === "permanent" ? 0 : new Date(form.expiresAtLocal).getTime();
|
|
const result = await banAppUser(activeUser.userId, {
|
|
expires_at_ms: expiresAtMs,
|
|
reason: form.reason,
|
|
});
|
|
patchUsers([mergeUserStatus(activeUser, result, "banned")]);
|
|
setSelectedUserIds((current) => current.filter((userId) => userId !== activeUser.userId));
|
|
closeAction();
|
|
});
|
|
};
|
|
|
|
const batchBan = async () => {
|
|
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
|
|
if (!users.length) {
|
|
showToast("请先选择未封禁用户", "warning");
|
|
return;
|
|
}
|
|
const ok = await confirm({
|
|
confirmText: "封禁",
|
|
message: `将永久封禁 ${users.length} 个用户。`,
|
|
title: "批量封禁用户",
|
|
tone: "danger",
|
|
});
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
|
|
setLoadingAction("batch-ban");
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
users.map((user) =>
|
|
banAppUser(user.userId, { expires_at_ms: 0, reason: "admin_batch_permanent_ban" }),
|
|
),
|
|
);
|
|
const updatedUsers = results
|
|
.map((result, index) =>
|
|
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
|
|
)
|
|
.filter(Boolean);
|
|
const failedCount = results.length - updatedUsers.length;
|
|
|
|
if (updatedUsers.length) {
|
|
patchUsers(updatedUsers);
|
|
setSelectedUserIds((current) =>
|
|
current.filter((userId) => !updatedUsers.some((updatedUser) => updatedUser.userId === userId)),
|
|
);
|
|
}
|
|
|
|
if (failedCount > 0) {
|
|
showToast(
|
|
updatedUsers.length ? `已封禁 ${updatedUsers.length} 个,${failedCount} 个失败` : "批量封禁失败",
|
|
updatedUsers.length ? "warning" : "error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
showToast(`已封禁 ${updatedUsers.length} 个用户`, "success");
|
|
} finally {
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const exportUsers = async () => {
|
|
setLoadingAction("export");
|
|
try {
|
|
// 创建任务时仅传当前筛选快照;页码由后端固定为首批并逐页导出,避免只导出当前可见页。
|
|
const created = await createAppUserExport(filters);
|
|
showToast(`导出任务 #${created.jobId} 已创建`, "success");
|
|
const job = await waitAppUserExportJob(created.jobId);
|
|
const response = await downloadAppUserExport(created.jobId);
|
|
await downloadResponse(response, appUserExportFilename(created.snapshotAtMs || job.createdAtMs));
|
|
showToast("用户 CSV 已生成", "success");
|
|
} catch (err) {
|
|
showToast(err.message || "导出用户失败", "error");
|
|
} finally {
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const runAction = async (action, successMessage, submitter) => {
|
|
setLoadingAction(action);
|
|
try {
|
|
await submitter();
|
|
showToast(successMessage, "success");
|
|
} catch (err) {
|
|
showToast(err.message || "操作失败", "error");
|
|
} finally {
|
|
setLoadingAction("");
|
|
}
|
|
};
|
|
|
|
const patchUsers = (users) => {
|
|
setPatchedUsers((current) => {
|
|
const next = { ...current };
|
|
users.forEach((user) => {
|
|
if (!user?.userId) {
|
|
return;
|
|
}
|
|
next[user.userId] = { ...(next[user.userId] || {}), ...user };
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return {
|
|
abilities,
|
|
activeAction,
|
|
activeUser,
|
|
banForm,
|
|
batchBan,
|
|
changeUserFilter,
|
|
changeStatus,
|
|
closeAction,
|
|
changeLocationFilter,
|
|
countryForm,
|
|
countryOptions,
|
|
data,
|
|
editForm,
|
|
error,
|
|
loading,
|
|
loadingAction,
|
|
loadingCountries,
|
|
loadingRegions,
|
|
levelForm,
|
|
locationFilterOptions,
|
|
locationFilterValue,
|
|
openCountry,
|
|
openCoinLedger,
|
|
openEdit,
|
|
openPassword,
|
|
page,
|
|
passwordForm,
|
|
regionId,
|
|
regionOptions,
|
|
reload,
|
|
resetFilters,
|
|
selectedUserIds,
|
|
setCountryForm,
|
|
setBanForm,
|
|
setEditForm,
|
|
setLevelForm,
|
|
setPage,
|
|
setPasswordForm,
|
|
setSelectedUserIds,
|
|
sortBy,
|
|
sortDirection,
|
|
status,
|
|
timeRange,
|
|
userFilter,
|
|
changeSort,
|
|
changeTimeRange,
|
|
submitCountry,
|
|
submitBan,
|
|
submitEdit,
|
|
submitLevels,
|
|
submitPassword,
|
|
toggleBan,
|
|
exportUsers,
|
|
};
|
|
}
|
|
|
|
function isBanned(user) {
|
|
return user.status === "banned" || user.status === "disabled";
|
|
}
|
|
|
|
function mergeUserStatus(currentUser, result, fallbackStatus) {
|
|
return {
|
|
...currentUser,
|
|
...(result || {}),
|
|
status: result?.status || fallbackStatus,
|
|
};
|
|
}
|
|
|
|
function levelFormFromUser(user) {
|
|
return {
|
|
charm: levelTrackForm(user?.levels?.charm),
|
|
reason: "",
|
|
wealth: levelTrackForm(user?.levels?.wealth),
|
|
};
|
|
}
|
|
|
|
function levelTrackForm(level) {
|
|
const realLevel = Number(level?.realLevel || 0);
|
|
const currentTarget = Number(level?.temporaryTargetLevel || level?.displayLevel || realLevel + 1);
|
|
const expiresAtMs = Number(level?.expiresAtMs || 0);
|
|
const remainingDays = expiresAtMs > Date.now() ? Math.max(1, Math.ceil((expiresAtMs - Date.now()) / 86400000)) : 7;
|
|
return {
|
|
durationDays: String(remainingDays),
|
|
enabled: false,
|
|
// 旧的临时等级也要保留到产品支持的 100 级,避免打开用户资料时把有效的高等级回填成 50。
|
|
level: String(Math.min(100, Math.max(1, currentTarget))),
|
|
};
|
|
}
|
|
|
|
async function waitAppUserExportJob(jobId) {
|
|
const deadline = Date.now() + 120000;
|
|
while (Date.now() < deadline) {
|
|
const job = await getAppUserExportJob(jobId);
|
|
if (job.status === "succeeded") {
|
|
return job;
|
|
}
|
|
if (["failed", "canceled", "cancelled"].includes(job.status)) {
|
|
throw new Error(job.error || "用户导出任务失败");
|
|
}
|
|
await delay(1500);
|
|
}
|
|
throw new Error("用户导出任务仍在处理中,请稍后重试");
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function futureDateTimeLocal(days) {
|
|
const date = new Date(Date.now() + days * 86400000);
|
|
const offsetMs = date.getTimezoneOffset() * 60000;
|
|
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
|
}
|
|
|
|
function appUserExportFilename(snapshotAtMs) {
|
|
const date = new Date(Number(snapshotAtMs || Date.now()));
|
|
const stamp = Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "snapshot";
|
|
return `app-users-${stamp}.csv`;
|
|
}
|