From 810ee6721ca0a17d53b4fa814e0a073f5999ec2f Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 30 Jun 2026 16:01:15 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=9D=93=E5=8F=B7=E6=94=B6?= =?UTF-8?q?=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 37 ++++++++++++++++ src/features/app-users/api.test.ts | 36 ++++++++++++++- src/features/app-users/api.ts | 16 +++++++ .../app-users/hooks/usePrettyIdsPage.js | 31 +++++++++++++ .../app-users/pages/AppUserPrettyIdsPage.jsx | 44 ++++++++++++++----- .../pages/AppUserPrettyIdsPage.test.jsx | 2 + src/shared/api/generated/endpoints.ts | 8 ++++ src/shared/api/generated/schema.d.ts | 36 +++++++++++++++ src/shared/api/types.ts | 4 ++ 9 files changed, 203 insertions(+), 11 deletions(-) diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index e414b0c..f894d92 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index 91fda28..be046f9 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -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", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 1d865af..4d7cf92 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -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 { + const endpoint = API_ENDPOINTS.recyclePrettyId; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.recyclePrettyId, { pretty_id: prettyId }), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizePrettyDisplayID(data); +} + export async function setPrettyDisplayIDStatus( prettyId: EntityId, payload: SetPrettyDisplayIDStatusPayload, diff --git a/src/features/app-users/hooks/usePrettyIdsPage.js b/src/features/app-users/hooks/usePrettyIdsPage.js index 144e904..4e896d1 100644 --- a/src/features/app-users/hooks/usePrettyIdsPage.js +++ b/src/features/app-users/hooks/usePrettyIdsPage.js @@ -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; +} diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx index 1c7d6ef..c9d506e 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx @@ -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 (
- page.openStatus(item)}> - - + {showStatus ? ( + page.openStatus(item)}> + + + ) : null} + {showRecycle ? ( + page.recyclePrettyID(item)} + > + + + ) : null}
- ) : ( - "-" - ), + ); + }, }, ]; } @@ -654,11 +671,11 @@ function StatusDialog({ page }) { ); } -function ActionIcon({ children, label, onClick }) { +function ActionIcon({ children, label, ...props }) { return ( - + {children} @@ -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; +} diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx index bb7cc40..26fcafa 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx @@ -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(), diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 8ae23d6..3862845 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index a814045..8d25527 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index d8b7cdd..8d26001 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -1023,6 +1023,10 @@ export interface GrantPrettyDisplayIDPayload { target_user_id: EntityId; } +export interface RecyclePrettyDisplayIDPayload { + reason?: string; +} + export interface SetPrettyDisplayIDStatusPayload { reason?: string; status: string;