用户靓号收回
This commit is contained in:
parent
b81c2edf43
commit
810ee6721c
@ -4802,6 +4802,43 @@
|
|||||||
"x-permissions": ["pretty-id:grant"]
|
"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": {
|
"/admin/users/pretty-ids/{pretty_id}/status": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "setPrettyIdStatus",
|
"operationId": "setPrettyIdStatus",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
|
import { getAppUser, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID } from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
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 () => {
|
test("listAppUsers sends country region and time filters", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
PrettyDisplayIDGenerationBatchDto,
|
PrettyDisplayIDGenerationBatchDto,
|
||||||
PrettyDisplayIDPoolDto,
|
PrettyDisplayIDPoolDto,
|
||||||
PrettyDisplayIDPoolPayload,
|
PrettyDisplayIDPoolPayload,
|
||||||
|
RecyclePrettyDisplayIDPayload,
|
||||||
SetPrettyDisplayIDStatusPayload,
|
SetPrettyDisplayIDStatusPayload,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
@ -180,6 +181,21 @@ export async function grantPrettyDisplayID(
|
|||||||
return normalizeAdminGrantPrettyDisplayIDResult(data);
|
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(
|
export async function setPrettyDisplayIDStatus(
|
||||||
prettyId: EntityId,
|
prettyId: EntityId,
|
||||||
payload: SetPrettyDisplayIDStatusPayload,
|
payload: SetPrettyDisplayIDStatusPayload,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import {
|
import {
|
||||||
createPrettyDisplayIDPool,
|
createPrettyDisplayIDPool,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
grantPrettyDisplayID,
|
grantPrettyDisplayID,
|
||||||
listPrettyDisplayIDPools,
|
listPrettyDisplayIDPools,
|
||||||
listPrettyDisplayIDs,
|
listPrettyDisplayIDs,
|
||||||
|
recyclePrettyDisplayID,
|
||||||
setPrettyDisplayIDStatus,
|
setPrettyDisplayIDStatus,
|
||||||
updatePrettyDisplayIDPool,
|
updatePrettyDisplayIDPool,
|
||||||
} from "@/features/app-users/api";
|
} from "@/features/app-users/api";
|
||||||
@ -36,6 +38,7 @@ const emptyStatusForm = () => ({ prettyId: "", reason: "", status: "available" }
|
|||||||
|
|
||||||
export function usePrettyIdsPage() {
|
export function usePrettyIdsPage() {
|
||||||
const abilities = useAppUserAbilities();
|
const abilities = useAppUserAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [poolPage, setPoolPage] = useState(1);
|
const [poolPage, setPoolPage] = useState(1);
|
||||||
const [poolStatus, setPoolStatus] = useState("");
|
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) => {
|
const changePoolStatus = (value) => {
|
||||||
setPoolStatus(value);
|
setPoolStatus(value);
|
||||||
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
// 任意筛选变化都回到第一页,避免旧页码在新条件下落到空页。
|
||||||
@ -333,6 +356,7 @@ export function usePrettyIdsPage() {
|
|||||||
reloadAll,
|
reloadAll,
|
||||||
resetIDFilters,
|
resetIDFilters,
|
||||||
resetPoolFilters,
|
resetPoolFilters,
|
||||||
|
recyclePrettyID,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setGenerateForm,
|
setGenerateForm,
|
||||||
setGrantForm,
|
setGrantForm,
|
||||||
@ -347,3 +371,10 @@ export function usePrettyIdsPage() {
|
|||||||
submitStatus,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||||
|
import AssignmentReturnOutlined from "@mui/icons-material/AssignmentReturnOutlined";
|
||||||
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
@ -365,16 +366,32 @@ function idColumns(page) {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "minmax(96px, 0.5fr)",
|
width: "minmax(96px, 0.5fr)",
|
||||||
render: (item) =>
|
render: (item) => {
|
||||||
page.abilities.canPrettyUpdate && canChangePrettyStatus(item) ? (
|
const showStatus = page.abilities.canPrettyUpdate && canChangePrettyStatus(item);
|
||||||
|
const showRecycle = page.abilities.canPrettyUpdate && canRecyclePrettyID(item);
|
||||||
|
if (!showStatus && !showRecycle) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
{showStatus ? (
|
||||||
<SettingsOutlined fontSize="small" />
|
<ActionIcon label="状态" onClick={() => page.openStatus(item)}>
|
||||||
</ActionIcon>
|
<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>
|
</div>
|
||||||
) : (
|
);
|
||||||
"-"
|
},
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -654,11 +671,11 @@ function StatusDialog({ page }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionIcon({ children, label, onClick }) {
|
function ActionIcon({ children, label, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Tooltip arrow title={label}>
|
<Tooltip arrow title={label}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton label={label} onClick={onClick}>
|
<IconButton label={label} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
@ -709,3 +726,10 @@ function canChangePrettyStatus(item) {
|
|||||||
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
assigned === 0 || assigned === "0" || assigned === "" || assigned === undefined || assigned === null;
|
||||||
return ["available", "disabled", "reserved"].includes(item.status) && unassigned;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ test("pretty id page uses tabs and hides internal pretty record ids", () => {
|
|||||||
expect(screen.getByText("占用人 A")).toBeInTheDocument();
|
expect(screen.getByText("占用人 A")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
|
expect(screen.getByText("Admin ID 8")).toBeInTheDocument();
|
||||||
expect(screen.getByText("释放 2026-06-27 01:00:00")).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", () => {
|
test("pretty id page can switch to pool tab", () => {
|
||||||
@ -107,6 +108,7 @@ function pageFixture(patch = {}) {
|
|||||||
reloadAll: vi.fn(),
|
reloadAll: vi.fn(),
|
||||||
resetIDFilters: vi.fn(),
|
resetIDFilters: vi.fn(),
|
||||||
resetPoolFilters: vi.fn(),
|
resetPoolFilters: vi.fn(),
|
||||||
|
recyclePrettyID: vi.fn(),
|
||||||
setActiveTab: vi.fn(),
|
setActiveTab: vi.fn(),
|
||||||
setGenerateForm: vi.fn(),
|
setGenerateForm: vi.fn(),
|
||||||
setGrantForm: vi.fn(),
|
setGrantForm: vi.fn(),
|
||||||
|
|||||||
@ -221,6 +221,7 @@ export const API_OPERATIONS = {
|
|||||||
me: "me",
|
me: "me",
|
||||||
navigationMenus: "navigationMenus",
|
navigationMenus: "navigationMenus",
|
||||||
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
publishHostAgencyPolicy: "publishHostAgencyPolicy",
|
||||||
|
recyclePrettyId: "recyclePrettyId",
|
||||||
refresh: "refresh",
|
refresh: "refresh",
|
||||||
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
|
||||||
replaceRegionBlocks: "replaceRegionBlocks",
|
replaceRegionBlocks: "replaceRegionBlocks",
|
||||||
@ -1757,6 +1758,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "host-agency-policy:publish",
|
permission: "host-agency-policy:publish",
|
||||||
permissions: ["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: {
|
refresh: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.refresh,
|
operationId: API_OPERATIONS.refresh,
|
||||||
|
|||||||
36
src/shared/api/generated/schema.d.ts
vendored
36
src/shared/api/generated/schema.d.ts
vendored
@ -3364,6 +3364,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/users/pretty-ids/{pretty_id}/status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7836,6 +7852,26 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
setPrettyIdStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -1023,6 +1023,10 @@ export interface GrantPrettyDisplayIDPayload {
|
|||||||
target_user_id: EntityId;
|
target_user_id: EntityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecyclePrettyDisplayIDPayload {
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetPrettyDisplayIDStatusPayload {
|
export interface SetPrettyDisplayIDStatusPayload {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user