用户靓号收回

This commit is contained in:
zhx 2026-06-30 16:01:15 +08:00
parent b81c2edf43
commit 810ee6721c
9 changed files with 203 additions and 11 deletions

View File

@ -4802,6 +4802,43 @@
"x-permissions": ["pretty-id:grant"]
}
},
"/admin/users/pretty-ids/{pretty_id}/recycle": {
"post": {
"operationId": "recyclePrettyId",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"name": "pretty_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reason": {
"type": "string"
}
}
}
}
}
},
"x-permission": "pretty-id:update",
"x-permissions": ["pretty-id:update"]
}
},
"/admin/users/pretty-ids/{pretty_id}/status": {
"post": {
"operationId": "setPrettyIdStatus",

View File

@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
afterEach(() => {
setAccessToken("");
@ -122,6 +122,40 @@ test("listPrettyDisplayIDs normalizes assigned user and operator snapshots", asy
});
});
test("recyclePrettyDisplayID uses generated recycle endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
assigned_user_id: "",
display_user_id: "777777",
pretty_id: "pretty-2",
released_at_ms: 1782493200000,
source: "admin_grant",
status: "released",
},
}),
),
),
);
const result = await recyclePrettyDisplayID("pretty-2");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/users/pretty-ids/pretty-2/recycle");
expect(init?.method).toBe("POST");
expect(result).toMatchObject({
assignedUserId: "",
displayUserId: "777777",
prettyId: "pretty-2",
status: "released",
});
});
test("listAppUsers sends country region and time filters", async () => {
vi.stubGlobal(
"fetch",

View File

@ -16,6 +16,7 @@ import type {
PrettyDisplayIDGenerationBatchDto,
PrettyDisplayIDPoolDto,
PrettyDisplayIDPoolPayload,
RecyclePrettyDisplayIDPayload,
SetPrettyDisplayIDStatusPayload,
} from "@/shared/api/types";
@ -180,6 +181,21 @@ export async function grantPrettyDisplayID(
return normalizeAdminGrantPrettyDisplayIDResult(data);
}
export async function recyclePrettyDisplayID(
prettyId: EntityId,
payload: RecyclePrettyDisplayIDPayload = {},
): Promise<PrettyDisplayIDDto> {
const endpoint = API_ENDPOINTS.recyclePrettyId;
const data = await apiRequest<RawPrettyDisplayID, RecyclePrettyDisplayIDPayload>(
apiEndpointPath(API_OPERATIONS.recyclePrettyId, { pretty_id: prettyId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizePrettyDisplayID(data);
}
export async function setPrettyDisplayIDStatus(
prettyId: EntityId,
payload: SetPrettyDisplayIDStatusPayload,

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createPrettyDisplayIDPool,
@ -8,6 +9,7 @@ import {
grantPrettyDisplayID,
listPrettyDisplayIDPools,
listPrettyDisplayIDs,
recyclePrettyDisplayID,
setPrettyDisplayIDStatus,
updatePrettyDisplayIDPool,
} from "@/features/app-users/api";
@ -36,6 +38,7 @@ const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" }
export function usePrettyIdsPage() {
const abilities = useAppUserAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [poolPage, setPoolPage] = useState(1);
const [poolStatus, setPoolStatus] = useState("");
@ -230,6 +233,26 @@ export function usePrettyIdsPage() {
});
};
const recyclePrettyID = async (item) => {
if (!abilities.canPrettyUpdate || !canRecyclePrettyID(item)) {
return;
}
const accepted = await confirm({
title: "回收靓号",
message: `确认回收靓号 ${item.displayUserId || item.prettyId}?回收后用户将恢复默认短号。`,
confirmText: "回收",
tone: "danger",
});
if (!accepted) {
return;
}
await runAction(`recycle-${item.prettyId}`, "靓号已回收", async () => {
// 回收只交给后台管理回收接口处理,前端不直接改 assigned/released 字段,避免和来源回收规则分叉。
await recyclePrettyDisplayID(item.prettyId);
await idQuery.reload();
});
};
const changePoolStatus = (value) => {
setPoolStatus(value);
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
@ -333,6 +356,7 @@ export function usePrettyIdsPage() {
reloadAll,
resetIDFilters,
resetPoolFilters,
recyclePrettyID,
setActiveTab,
setGenerateForm,
setGrantForm,
@ -347,3 +371,10 @@ export function usePrettyIdsPage() {
submitStatus,
};
}
function canRecyclePrettyID(item) {
const assigned = item?.assignedUserId;
const hasAssignedUser =
assigned !== 0 && assigned !== "0" && assigned !== "" && assigned !== undefined && assigned !== null;
return Boolean(item?.prettyId) && item.status === "assigned" && hasAssignedUser;
}

View File

@ -1,4 +1,5 @@
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
import AssignmentReturnOutlined from "@mui/icons-material/AssignmentReturnOutlined";
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
@ -365,16 +366,32 @@ function idColumns(page) {
key: "actions",
label: "操作",
width: "minmax(96px, 0.5fr)",
render: (item) =>
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
render: (item) => {
const showStatus = page.abilities.canPrettyUpdate && canChangePrettyStatus(item);
const showRecycle = page.abilities.canPrettyUpdate && canRecyclePrettyID(item);
if (!showStatus && !showRecycle) {
return "-";
}
return (
<div className={styles.rowActions}>
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
<SettingsOutlined fontSize="small" />
</ActionIcon>
{showStatus ? (
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
<SettingsOutlined fontSize="small" />
</ActionIcon>
) : null}
{showRecycle ? (
<ActionIcon
disabled={page.loadingAction === `recycle-${item.prettyId}`}
label="回收"
tone="danger"
onClick={() => page.recyclePrettyID(item)}
>
<AssignmentReturnOutlined fontSize="small" />
</ActionIcon>
) : null}
</div>
) : (
"-"
),
);
},
},
];
}
@ -654,11 +671,11 @@ function StatusDialog({ page }) {
);
}
function ActionIcon({ children, label, onClick }) {
function ActionIcon({ children, label, ...props }) {
return (
<Tooltip arrow title={label}>
<span>
<IconButton label={label} onClick={onClick}>
<IconButton label={label} {...props}>
{children}
</IconButton>
</span>
@ -709,3 +726,10 @@ function canChangePrettyStatus(item) {
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
}
function canRecyclePrettyID(item) {
const assigned = item.assignedUserId;
const hasAssignedUser =
assigned !== 0 && assigned !== "0" && assigned !== "" && assigned !== undefined && assigned !== null;
return item.status === "assigned" && hasAssignedUser;
}

View File

@ -23,6 +23,7 @@ test("pretty id page uses tabs and hides internal pretty record ids", () => {
expect(screen.getByText("占用人 A")).toBeInTheDocument();
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument();
expect(screen.getAllByLabelText("回收").length).toBeGreaterThan(0);
});
test("pretty id page can switch to pool tab", () => {
@ -107,6 +108,7 @@ function pageFixture(patch = {}) {
reloadAll: vi.fn(),
resetIDFilters: vi.fn(),
resetPoolFilters: vi.fn(),
recyclePrettyID: vi.fn(),
setActiveTab: vi.fn(),
setGenerateForm: vi.fn(),
setGrantForm: vi.fn(),

View File

@ -221,6 +221,7 @@ export const API_OPERATIONS = {
me: "me",
navigationMenus: "navigationMenus",
publishHostAgencyPolicy: "publishHostAgencyPolicy",
recyclePrettyId: "recyclePrettyId",
refresh: "refresh",
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
replaceRegionBlocks: "replaceRegionBlocks",
@ -1757,6 +1758,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "host-agency-policy:publish",
permissions: ["host-agency-policy:publish"]
},
recyclePrettyId: {
method: "POST",
operationId: API_OPERATIONS.recyclePrettyId,
path: "/v1/admin/users/pretty-ids/{pretty_id}/recycle",
permission: "pretty-id:update",
permissions: ["pretty-id:update"]
},
refresh: {
method: "POST",
operationId: API_OPERATIONS.refresh,

View File

@ -3364,6 +3364,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/users/pretty-ids/{pretty_id}/recycle": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["recyclePrettyId"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/users/pretty-ids/{pretty_id}/status": {
parameters: {
query?: never;
@ -7836,6 +7852,26 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
recyclePrettyId: {
parameters: {
query?: never;
header?: never;
path: {
pretty_id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
reason?: string;
};
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
setPrettyIdStatus: {
parameters: {
query?: never;

View File

@ -1023,6 +1023,10 @@ export interface GrantPrettyDisplayIDPayload {
target_user_id: EntityId;
}
export interface RecyclePrettyDisplayIDPayload {
reason?: string;
}
export interface SetPrettyDisplayIDStatusPayload {
reason?: string;
status: string;