From 63d4ffbacd6f1887f6e6e825982ce0763e2926b0 Mon Sep 17 00:00:00 2001 From: zhx Date: Sat, 27 Jun 2026 01:09:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=9D=93=E5=8F=B7=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/app-users/api.test.ts | 62 +++ src/features/app-users/api.ts | 52 +- src/features/app-users/app-users.module.css | 37 ++ .../app-users/hooks/usePrettyIdsPage.js | 3 + .../app-users/pages/AppUserPrettyIdsPage.jsx | 474 ++++++++++-------- .../pages/AppUserPrettyIdsPage.test.jsx | 169 +++++++ src/shared/api/types.ts | 21 + 7 files changed, 596 insertions(+), 222 deletions(-) create mode 100644 src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index a88042a..91fda28 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -60,6 +60,68 @@ test("listPrettyDisplayIDs uses admin endpoint and normalizes snake case fields" }); }); +test("listPrettyDisplayIDs normalizes assigned user and operator snapshots", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + items: [ + { + assigned_user: { + avatar: "https://cdn.example/u.png", + default_display_user_id: "10001", + display_user_id: "777777", + user_id: "9001", + username: "pretty owner", + }, + assigned_user_id: "9001", + created_by_admin_id: "7", + display_user_id: "777777", + operator: { + account: "ops", + admin_id: "8", + name: "运营", + type: "admin", + }, + pretty_id: "pretty-2", + source: "admin_grant", + status: "assigned", + updated_by_admin_id: "8", + }, + ], + page: 1, + page_size: 20, + total: 1, + }, + }), + ), + ), + ); + + const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 }); + + expect(result.items[0]).toMatchObject({ + assignedUser: { + avatar: "https://cdn.example/u.png", + defaultDisplayUserId: "10001", + displayUserId: "777777", + userId: "9001", + username: "pretty owner", + }, + operator: { + account: "ops", + adminId: "8", + name: "运营", + type: "admin", + }, + updatedByAdminId: "8", + }); +}); + 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 4804b88..1d865af 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -386,6 +386,8 @@ interface RawPrettyDisplayID { assignedAtMs?: number; assigned_lease_id?: string; assignedLeaseId?: string; + assigned_user?: RawAppUserBrief; + assignedUser?: RawAppUserBrief; assigned_user_id?: EntityId; assignedUserId?: EntityId; created_at_ms?: number; @@ -396,6 +398,7 @@ interface RawPrettyDisplayID { displayUserId?: string; generated_batch_id?: string; generatedBatchId?: string; + operator?: RawPrettyDisplayIDOperator | null; pool?: RawPrettyDisplayIDPool | null; pool_id?: string; poolId?: string; @@ -409,6 +412,17 @@ interface RawPrettyDisplayID { status?: string; updated_at_ms?: number; updatedAtMs?: number; + updated_by_admin_id?: EntityId; + updatedByAdminId?: EntityId; +} + +interface RawPrettyDisplayIDOperator extends RawAppUserBrief { + account?: string; + admin_id?: EntityId; + adminId?: EntityId; + name?: string; + source?: string; + type?: string; } interface RawPrettyDisplayIDGenerationBatch { @@ -572,6 +586,37 @@ function normalizeAppUserBanOperator(item?: RawAppUserBanOperator | null) { }; } +function normalizePrettyDisplayIDOperator(item?: RawPrettyDisplayIDOperator | null, fallbackAdminId?: EntityId) { + if (!item) { + const adminId = stringValue(fallbackAdminId); + return adminId + ? { + adminId, + name: "后台管理员", + type: "admin", + } + : undefined; + } + const user = normalizeAppUserBrief(item); + const adminId = stringValue(item.adminId ?? item.admin_id ?? fallbackAdminId); + return { + ...user, + account: stringValue(item.account), + adminId, + name: stringValue(item.name), + source: stringValue(item.source), + type: stringValue(item.type || (adminId ? "admin" : "app_user")), + }; +} + +function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) { + if (!item) { + return undefined; + } + const user = normalizeAppUserBrief(item); + return user.userId || user.displayUserId || user.username ? user : undefined; +} + function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto { return { createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), @@ -640,15 +685,19 @@ function numberValue(value: unknown): number { // 靓号明细包含池号和后台发放两类来源,pool 可能为空,归一化后页面只判断 source/status 即可。 function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto { + const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? ""; + const updatedByAdminId = item.updatedByAdminId ?? item.updated_by_admin_id ?? ""; return { appCode: item.appCode ?? item.app_code ?? "", assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0, assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "", + assignedUser: normalizeOptionalAppUserBrief(item.assignedUser ?? item.assigned_user), assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "", createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0, - createdByAdminId: item.createdByAdminId ?? item.created_by_admin_id ?? "", + createdByAdminId, displayUserId: item.displayUserId ?? item.display_user_id ?? "", generatedBatchId: item.generatedBatchId ?? item.generated_batch_id ?? "", + operator: normalizePrettyDisplayIDOperator(item.operator, updatedByAdminId || createdByAdminId), pool: item.pool ? normalizePrettyDisplayIDPool(item.pool) : null, poolId: item.poolId ?? item.pool_id ?? "", prettyId: item.prettyId ?? item.pretty_id ?? "", @@ -657,6 +706,7 @@ function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto source: item.source ?? "", status: item.status ?? "", updatedAtMs: item.updatedAtMs ?? item.updated_at_ms ?? 0, + updatedByAdminId, }; } diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 32d6538..d5c6c67 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -34,6 +34,39 @@ gap: 0; } +.prettyTabsBar { + display: flex; + min-height: 48px; + align-items: center; + flex: 0 0 auto; + padding: 0 var(--space-4); + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} + +.prettyTabsBar :global(.MuiTabs-indicator) { + height: 3px; + border-radius: 999px 999px 0 0; + background: var(--primary); +} + +.prettyTab.prettyTab { + min-height: 48px; + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 750; + letter-spacing: 0; + text-transform: none; +} + +.prettyTab.prettyTab:hover { + color: var(--text-primary); +} + +.prettyTab.prettyTab:global(.Mui-selected) { + color: var(--primary); +} + .prettyWorkspace { display: grid; flex: 1; @@ -637,6 +670,10 @@ } @media (max-width: 760px) { + .prettyTabsBar { + padding: 0 var(--space-3); + } + .prettyWorkspace { grid-template-columns: 1fr; } diff --git a/src/features/app-users/hooks/usePrettyIdsPage.js b/src/features/app-users/hooks/usePrettyIdsPage.js index a6fa39c..144e904 100644 --- a/src/features/app-users/hooks/usePrettyIdsPage.js +++ b/src/features/app-users/hooks/usePrettyIdsPage.js @@ -46,6 +46,7 @@ export function usePrettyIdsPage() { const [idSource, setIdSource] = useState(""); const [idPoolId, setIdPoolId] = useState(""); const [assignedUserId, setAssignedUserId] = useState(""); + const [activeTab, setActiveTab] = useState("ids"); const [activeDialog, setActiveDialog] = useState(""); const [activePool, setActivePool] = useState(null); const [activePrettyID, setActivePrettyID] = useState(null); @@ -299,6 +300,7 @@ export function usePrettyIdsPage() { activeDialog, activePool, activePrettyID, + activeTab, assignedUserId, changeAssignedUserId, changeIDKeyword, @@ -331,6 +333,7 @@ export function usePrettyIdsPage() { reloadAll, resetIDFilters, resetPoolFilters, + setActiveTab, setGenerateForm, setGrantForm, setIdPage, diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx index e7fe98e..1c7d6ef 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx @@ -5,14 +5,16 @@ import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined"; import SendOutlined from "@mui/icons-material/SendOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import MenuItem from "@mui/material/MenuItem"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; -import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { prettyIdSourceLabels, @@ -31,199 +33,22 @@ export function AppUserPrettyIdsPage() { const page = usePrettyIdsPage(); const pools = page.poolQuery.data.items || []; const prettyIds = page.idQuery.data.items || []; + const showingPools = page.activeTab === "pools"; return (
-
-
- - {page.abilities.canPrettyGenerate ? ( - - ) : null} - {page.abilities.canPrettyUpdate ? ( - - ) : null} - - } - count={page.poolQuery.data.total} - title="靓号池" - /> -
- page.changePoolTrack(event.target.value)} - > - 全部等级轨道 - {prettyLevelTrackOptions.map(([value, label]) => ( - - {label} - - ))} - - page.changePoolStatus(event.target.value)} - > - {prettyPoolStatusOptions.map(([value, label]) => ( - - {label} - - ))} - - -
- - 0 - ? { - page: page.poolPage, - pageSize: page.poolQuery.data.pageSize || 20, - total: page.poolQuery.data.total, - onPageChange: page.setPoolPage, - } - : undefined - } - rowKey={(pool) => pool.poolId} - /> - -
- -
- } - variant="primary" - onClick={page.openGrant} - > - 后台发放 - - ) : null - } - count={page.idQuery.data.total} - title="靓号列表" - /> -
- page.changeIDKeyword(event.target.value)} - /> - page.changeIDPool(event.target.value)} - > - 全部靓号池 - {page.poolOptions.map((option) => ( - - {option.label} - - ))} - - page.changeIDSource(event.target.value)} - > - {prettyIdSourceOptions.map(([value, label]) => ( - - {label} - - ))} - - page.changeIDStatus(event.target.value)} - > - {prettyIdStatusOptions.map(([value, label]) => ( - - {label} - - ))} - - page.changeAssignedUserId(event.target.value)} - /> - -
- - 0 - ? { - page: page.idPage, - pageSize: page.idQuery.data.pageSize || 20, - total: page.idQuery.data.total, - onPageChange: page.setIdPage, - } - : undefined - } - rowKey={(item) => item.prettyId} - /> - -
+
+ page.setActiveTab(value)}> + + +
+ {showingPools ? ( + + ) : ( + + )}
@@ -234,6 +59,190 @@ export function AppUserPrettyIdsPage() { ); } +function PrettyPoolPanel({ page, pools }) { + return ( +
+ + {page.abilities.canPrettyGenerate ? ( + + ) : null} + {page.abilities.canPrettyUpdate ? ( + + ) : null} + + } + count={page.poolQuery.data.total} + title="靓号池" + /> +
+ page.changePoolTrack(event.target.value)} + > + 全部等级轨道 + {prettyLevelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changePoolStatus(event.target.value)} + > + {prettyPoolStatusOptions.map(([value, label]) => ( + + {label} + + ))} + + +
+ + 0 + ? { + page: page.poolPage, + pageSize: page.poolQuery.data.pageSize || 20, + total: page.poolQuery.data.total, + onPageChange: page.setPoolPage, + } + : undefined + } + rowKey={(pool) => pool.poolId} + /> + +
+ ); +} + +function PrettyIDPanel({ page, prettyIds }) { + return ( +
+ } variant="primary" onClick={page.openGrant}> + 后台发放 + + ) : null + } + count={page.idQuery.data.total} + title="靓号列表" + /> +
+ page.changeIDKeyword(event.target.value)} + /> + page.changeIDPool(event.target.value)} + > + 全部靓号池 + {page.poolOptions.map((option) => ( + + {option.label} + + ))} + + page.changeIDSource(event.target.value)} + > + {prettyIdSourceOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changeIDStatus(event.target.value)} + > + {prettyIdStatusOptions.map(([value, label]) => ( + + {label} + + ))} + + page.changeAssignedUserId(event.target.value)} + /> + +
+ + 0 + ? { + page: page.idPage, + pageSize: page.idQuery.data.pageSize || 20, + total: page.idQuery.data.total, + onPageChange: page.setIdPage, + } + : undefined + } + rowKey={(item) => item.prettyId} + /> + +
+ ); +} + function PaneHeader({ actions, count, title }) { return (
@@ -306,13 +315,12 @@ function idColumns(page) { { key: "display", label: "靓号", - width: "minmax(190px, 1fr)", + width: "minmax(130px, 0.7fr)", render: (item) => (
{item.displayUserId || "-"}
-
{item.prettyId}
), }, @@ -334,21 +342,25 @@ function idColumns(page) { render: (item) => , }, { - key: "assigned", - label: "占用 / 时间", - width: "minmax(190px, 0.9fr)", + key: "assignedUser", + label: "占用人", + width: "minmax(240px, 1.2fr)", render: (item) => ( -
- - - {item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)} - - - {item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"} - -
+ ), }, + { + key: "operator", + label: "操作人", + width: "minmax(210px, 1fr)", + render: (item) => , + }, + { + key: "time", + label: "占用 / 释放时间", + width: "minmax(210px, 1fr)", + render: (item) => , + }, { key: "actions", label: "操作", @@ -368,31 +380,51 @@ function idColumns(page) { } function AssignedUserCell({ item }) { - const { openAppUserDetail } = useAppUserDetail(); const assignedUserId = formatEntityID(item.assignedUserId); - if (!item.assignedUserId || !openAppUserDetail) { - return {assignedUserId}; + if (!item.assignedUserId) { + return -; } + return ; +} + +function OperatorCell({ item }) { + const operator = item.operator || {}; + const adminId = operator.adminId || item.updatedByAdminId || item.createdByAdminId; + if (!operator.userId && !operator.adminId && !operator.name && !operator.account && !adminId) { + return -; + } + if ((operator.type || "admin") === "app_user") { + return ; + } return ( - + ); } +function PrettyIDTimeCell({ item }) { + return ( +
+ {item.assignedAtMs ? formatMillis(item.assignedAtMs) : "-"} + {item.releasedAtMs ? `释放 ${formatMillis(item.releasedAtMs)}` : "-"} +
+ ); +} + +function assignedUser(item) { + return { + ...(item.assignedUser || {}), + displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId, + prettyDisplayUserId: item.displayUserId, + prettyId: item.prettyId, + userId: item.assignedUser?.userId || item.assignedUserId, + }; +} + function PoolDialog({ page }) { const editing = Boolean(page.activePool); return ( diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx new file mode 100644 index 0000000..bb7cc40 --- /dev/null +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx @@ -0,0 +1,169 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { AppUserPrettyIdsPage } from "./AppUserPrettyIdsPage.jsx"; +import { usePrettyIdsPage } from "@/features/app-users/hooks/usePrettyIdsPage.js"; + +vi.mock("@/features/app-users/hooks/usePrettyIdsPage.js", () => ({ + usePrettyIdsPage: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("pretty id page uses tabs and hides internal pretty record ids", () => { + vi.mocked(usePrettyIdsPage).mockReturnValue(pageFixture()); + + render(); + + expect(screen.getByRole("tab", { name: "靓号列表 1" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "靓号池 1" })).toBeInTheDocument(); + expect(screen.getAllByText("777777").length).toBeGreaterThan(0); + expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.toBeInTheDocument(); + expect(screen.getByText("占用人 A")).toBeInTheDocument(); + expect(screen.getByText("Admin ID 8")).toBeInTheDocument(); + expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument(); +}); + +test("pretty id page can switch to pool tab", () => { + const page = pageFixture({ activeTab: "pools" }); + vi.mocked(usePrettyIdsPage).mockReturnValue(page); + + render(); + + expect(screen.getByRole("tab", { name: "靓号池 1" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("≥LV.10")).toBeInTheDocument(); +}); + +function pageFixture(patch = {}) { + return { + abilities: { + canPrettyGenerate: true, + canPrettyGrant: true, + canPrettyUpdate: true, + }, + activeDialog: "", + activePool: null, + activePrettyID: null, + activeTab: "ids", + assignedUserId: "", + changeAssignedUserId: vi.fn(), + changeIDKeyword: vi.fn(), + changeIDPool: vi.fn(), + changeIDSource: vi.fn(), + changeIDStatus: vi.fn(), + changePoolStatus: vi.fn(), + changePoolTrack: vi.fn(), + closeDialog: vi.fn(), + generateForm: { count: 100, poolId: "", ruleConfigJson: "", ruleType: "aabbcc" }, + grantForm: { displayUserId: "", durationDays: "", reason: "", targetUserId: "" }, + idKeyword: "", + idPage: 1, + idPoolId: "", + idQuery: { + data: { + items: [prettyIdFixture()], + page: 1, + pageSize: 20, + total: 1, + }, + error: null, + loading: false, + reload: vi.fn(), + }, + idSource: "", + idStatus: "", + loadingAction: "", + openCreatePool: vi.fn(), + openEditPool: vi.fn(), + openGenerate: vi.fn(), + openGrant: vi.fn(), + openStatus: vi.fn(), + poolForm: { + levelTrack: "wealth", + maxLevel: 19, + minLevel: 10, + name: "", + ruleConfigJson: "", + ruleType: "aabbcc", + sortOrder: 0, + status: "active", + }, + poolOptions: [{ label: "≥LV.10 · 10-19", value: "pool-1" }], + poolPage: 1, + poolQuery: { + data: { + items: [poolFixture()], + page: 1, + pageSize: 20, + total: 1, + }, + error: null, + loading: false, + reload: vi.fn(), + }, + poolStatus: "", + poolTrack: "", + reloadAll: vi.fn(), + resetIDFilters: vi.fn(), + resetPoolFilters: vi.fn(), + setActiveTab: vi.fn(), + setGenerateForm: vi.fn(), + setGrantForm: vi.fn(), + setIdPage: vi.fn(), + setPoolForm: vi.fn(), + setPoolPage: vi.fn(), + setStatusForm: vi.fn(), + statusForm: { prettyId: "", reason: "", status: "available" }, + submitGenerate: vi.fn(), + submitGrant: vi.fn(), + submitPool: vi.fn(), + submitStatus: vi.fn(), + ...patch, + }; +} + +function poolFixture() { + return { + createdAtMs: 1781249886288, + levelTrack: "wealth", + maxLevel: 19, + minLevel: 10, + name: "≥LV.10", + poolId: "pretty_pool_1781249886288_301d1a449c51fbc0", + ruleType: "abccba", + status: "active", + updatedAtMs: 1781249886288, + }; +} + +function prettyIdFixture() { + return { + assignedAtMs: 1782499200000, + assignedUser: { + avatar: "", + defaultDisplayUserId: "10001", + displayUserId: "777777", + userId: "9001", + username: "占用人 A", + }, + assignedUserId: "9001", + createdAtMs: 1782499100000, + createdByAdminId: "7", + displayUserId: "777777", + operator: { + account: "ops", + adminId: "8", + name: "运营", + type: "admin", + }, + pool: poolFixture(), + poolId: "pool-1", + prettyId: "pretty_1782492525506_0b70bb6c6dbc63cf", + releasedAtMs: 1782493200000, + source: "admin_grant", + status: "assigned", + updatedAtMs: 1782499300000, + updatedByAdminId: "8", + }; +} diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index ec9d102..a6b0c1b 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -933,11 +933,31 @@ export interface PrettyDisplayIDDto { appCode?: string; assignedAtMs?: number; assignedLeaseId?: string; + assignedUser?: { + avatar?: string; + defaultDisplayUserId?: string; + displayUserId?: string; + prettyDisplayUserId?: string; + prettyId?: string; + userId?: EntityId; + username?: string; + }; assignedUserId?: EntityId; createdAtMs?: number; createdByAdminId?: EntityId; displayUserId: string; generatedBatchId?: string; + operator?: { + account?: string; + adminId?: EntityId; + avatar?: string; + displayUserId?: string; + name?: string; + source?: string; + type?: string; + userId?: EntityId; + username?: string; + }; pool?: PrettyDisplayIDPoolDto | null; poolId?: string; prettyId: string; @@ -946,6 +966,7 @@ export interface PrettyDisplayIDDto { source: string; status: string; updatedAtMs?: number; + updatedByAdminId?: EntityId; } export interface PrettyDisplayIDGenerationBatchDto {