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`; }