2026-06-29 14:26:00 +08:00

359 lines
12 KiB
JavaScript

import { useEffect, useMemo, useState } from "react";
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 {
banAppUser,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, 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: "" });
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 [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: "" });
setPage(1);
};
const openEdit = (user) => {
setActiveUser(user);
setEditForm({
avatar: user.avatar || "",
gender: user.gender || "",
username: user.username || "",
});
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);
await updateAppUser(activeUser.userId, payload);
closeAction();
await reload();
});
};
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);
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
});
};
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)));
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 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,
batchBan,
changeUserFilter,
changeStatus,
closeAction,
changeLocationFilter,
countryForm,
countryOptions,
data,
editForm,
error,
loading,
loadingAction,
loadingCountries,
loadingRegions,
locationFilterOptions,
locationFilterValue,
openCountry,
openCoinLedger,
openEdit,
openPassword,
page,
passwordForm,
regionId,
regionOptions,
reload,
resetFilters,
selectedUserIds,
setCountryForm,
setEditForm,
setPage,
setPasswordForm,
setSelectedUserIds,
sortBy,
sortDirection,
status,
timeRange,
userFilter,
changeSort,
changeTimeRange,
submitCountry,
submitEdit,
submitPassword,
toggleBan,
};
}
function isBanned(user) {
return user.status === "banned" || user.status === "disabled";
}
function mergeUserStatus(currentUser, result, fallbackStatus) {
return {
...currentUser,
...(result || {}),
status: result?.status || fallbackStatus,
};
}