diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 69aa964..c80ad89 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -881,6 +881,28 @@ "x-permissions": ["agency:status"] } }, + "/admin/agencies/{agency_id}/status": { + "patch": { + "operationId": "setAgencyStatus", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "agency_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "agency:status", + "x-permissions": ["agency:status"] + } + }, "/admin/agencies/{agency_id}/delete": { "post": { "operationId": "deleteAgency", @@ -5757,6 +5779,9 @@ "agencyId": { "type": "integer" }, + "activeHostCount": { + "type": "integer" + }, "createdAtMs": { "type": "integer" }, @@ -5849,6 +5874,9 @@ "type": "object", "required": ["userId"], "properties": { + "agencyCount": { + "type": "integer" + }, "createdAtMs": { "type": "integer" }, diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index 689cdef..d104e04 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -60,6 +60,7 @@ const writableBridgeAdapterTypes = new Set([ "baishun_v1", "vivagames_v1", "reyou_v1", + "zgame_v1", ]); const emptyCatalog = { items: [], pageSize: 50 }; diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 0c30eae..2c7043c 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -51,6 +51,7 @@ const adapterTypeOptions = [ ["baishun_v1", "百顺 BAISHUN V1"], ["vivagames_v1", "VIVAGAMES V1"], ["reyou_v1", "热游 Reyou V1"], + ["zgame_v1", "ZGame V1"], ]; const baseColumns = [ diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index 49b23dc..5c90b96 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -4,6 +4,7 @@ export { listRegions } from "@/shared/api/regions"; import type { AgencyDto, AgencyJoinEnabledPayload, + AgencyStatusPayload, ApiList, ApiPage, BDLeaderPositionAliasPayload, @@ -382,6 +383,17 @@ export function deleteAgency(agencyId: EntityId, payload: HostCommandPayload): P ); } +export function setAgencyStatus(agencyId: EntityId, payload: AgencyStatusPayload): Promise { + const endpoint = API_ENDPOINTS.setAgencyStatus; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setAgencyStatus, { agency_id: agencyId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise { const endpoint = API_ENDPOINTS.setAgencyJoinEnabled; return apiRequest( diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js index aa485ac..d5d07c5 100644 --- a/src/features/host-org/hooks/useHostAgenciesPage.js +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -3,7 +3,7 @@ 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 { closeAgency, createAgency, deleteAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api"; +import { createAgency, deleteAgency, listAgencies, setAgencyJoinEnabled, setAgencyStatus } from "@/features/host-org/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js"; import { useAgencyAbilities } from "@/features/host-org/permissions.js"; @@ -146,20 +146,32 @@ export function useHostAgenciesPage() { }); }; - const closeAgencyFromList = async (agency) => { - if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { + const toggleAgencyStatusFromList = async (agency, enabled) => { + if (!abilities.canStatus || !agency?.agencyId) { return; } - await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => { - const data = await closeAgency(agency.agencyId, { - commandId: makeCommandId("agency-close"), - reason: "close agency from list switch", + const nextStatus = enabled ? "active" : "closed"; + if (agency.status === nextStatus || agency.status === "deleted") { + return; + } + await runAction(`agency-status-${agency.agencyId}`, enabled ? "Agency 已启用" : "Agency 已关闭", async () => { + const data = await setAgencyStatus(agency.agencyId, { + commandId: makeCommandId("agency-status"), + reason: enabled ? "open agency from list switch" : "close agency from list switch", + status: nextStatus, }); - patchAgencyRow(agency.agencyId, agencyActionPatch(data, { status: "closed", joinEnabled: false })); + patchAgencyRow( + agency.agencyId, + agencyActionPatch(data, enabled ? { status: nextStatus } : { status: nextStatus, joinEnabled: false }), + ); return data; }); }; + const closeAgencyFromList = async (agency) => { + await toggleAgencyStatusFromList(agency, false); + }; + const toggleAgencyJoinEnabledFromList = async (agency, joinEnabled) => { if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { return; @@ -248,6 +260,7 @@ export function useHostAgenciesPage() { status, submitAgency, changeSort, + toggleAgencyStatusFromList, toggleAgencyJoinEnabledFromList, }; } @@ -258,7 +271,7 @@ function makeCommandId(prefix) { function agencyActionPatch(data, fallback = {}) { const patch = { ...fallback }; - for (const key of ["status", "joinEnabled", "updatedAtMs"]) { + for (const key of ["activeHostCount", "status", "joinEnabled", "updatedAtMs"]) { if (data?.[key] !== undefined) { patch[key] = data[key]; } diff --git a/src/features/host-org/hooks/useHostHostsPage.js b/src/features/host-org/hooks/useHostHostsPage.js index 544345e..dd392a3 100644 --- a/src/features/host-org/hooks/useHostHostsPage.js +++ b/src/features/host-org/hooks/useHostHostsPage.js @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { listHosts } from "@/features/host-org/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; @@ -11,10 +12,12 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function useHostHostsPage() { const abilities = useHostAbilities(); const { loadingRegions, regionOptions } = useRegionOptions(); + const [searchParams, setSearchParams] = useSearchParams(); + const agencyIdParam = searchParams.get("agency_id") || searchParams.get("agencyId") || ""; const [userFilter, setUserFilter] = useState(emptyUserIdentityFilter); const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); - const [agencyId, setAgencyId] = useState(""); + const [agencyId, setAgencyId] = useState(() => agencyIdParam); const [sortBy, setSortBy] = useState(""); const [sortDirection, setSortDirection] = useState(""); const [page, setPage] = useState(1); @@ -43,6 +46,14 @@ export function useHostHostsPage() { queryKey: ["host-org", "hosts", filters, page], }); + useEffect(() => { + if (agencyId === agencyIdParam) { + return; + } + setAgencyId(agencyIdParam); + setPage(1); + }, [agencyId, agencyIdParam]); + const changeUserFilter = (value) => { setUserFilter(value); setPage(1); @@ -58,9 +69,23 @@ export function useHostHostsPage() { setPage(1); }; + const syncAgencyIdSearchParam = (value) => { + const nextSearchParams = new URLSearchParams(searchParams); + const normalizedValue = String(value || "").trim(); + if (normalizedValue) { + nextSearchParams.set("agency_id", normalizedValue); + nextSearchParams.delete("agencyId"); + } else { + nextSearchParams.delete("agency_id"); + nextSearchParams.delete("agencyId"); + } + setSearchParams(nextSearchParams, { replace: true }); + }; + const changeAgencyId = (value) => { setAgencyId(value); setPage(1); + syncAgencyIdSearchParam(value); }; const changeSort = (field) => { @@ -77,6 +102,7 @@ export function useHostHostsPage() { setSortBy(""); setSortDirection(""); setPage(1); + syncAgencyIdSearchParam(""); }; return { diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index 8a8f57c..825e071 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -234,6 +234,29 @@ font-weight: 650; } +.hostCountButton { + display: inline-flex; + min-width: 44px; + min-height: 28px; + align-items: center; + justify-content: flex-start; + padding: 0; + border: 0; + background: transparent; + color: var(--primary); + cursor: pointer; + font: inherit; + font-weight: 760; + letter-spacing: 0; +} + +.hostCountButton:hover, +.hostCountButton:focus-visible { + color: var(--primary-strong); + text-decoration: underline; + text-underline-offset: 3px; +} + .inlineAliasText { color: var(--text-secondary); font-weight: 650; diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index 64390a1..89ae274 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -2,6 +2,7 @@ import Add from "@mui/icons-material/Add"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; +import { useNavigate } from "react-router-dom"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; import { agencyStatusFilters } from "@/features/host-org/constants.js"; @@ -29,6 +30,12 @@ const agencyColumns = [ render: (item) => , width: "minmax(220px, 1.2fr)", }, + { + key: "activeHostCount", + label: "子 Host", + render: (item, _index, context) => , + width: "minmax(110px, 0.65fr)", + }, { key: "salaryWallet", label: "工资钱包", @@ -84,9 +91,16 @@ const agencyColumns = [ export function HostAgenciesPage() { const page = useHostAgenciesPage(); + const navigate = useNavigate(); const createDisabled = !page.abilities.canCreate; const items = page.data.items || []; const total = page.data.total || 0; + const openAgencyHosts = (agency) => { + if (!agency?.agencyId) { + return; + } + navigate(`/host/hosts?agency_id=${encodeURIComponent(String(agency.agencyId))}`); + }; const toolbarActions = [ page.abilities.canCreate ? { icon: , label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" } @@ -158,9 +172,9 @@ export function HostAgenciesPage() {
0 ? { @@ -251,18 +265,28 @@ function ParentBD({ item }) { ); } +function AgencyHostCount({ item, onOpen }) { + const count = Number(item.activeHostCount ?? item.hostCount ?? 0); + return ( + + ); +} + function AgencyStatusSwitch({ item, page }) { const active = item.status === "active"; return ( { - if (!event.target.checked) { - page.closeAgencyFromList(item); - } - }} + disabled={!page?.abilities.canStatus || page.loadingAction === `agency-status-${item.agencyId}`} + inputProps={{ "aria-label": active ? "关闭 Agency" : "启用 Agency" }} + onChange={(event) => page.toggleAgencyStatusFromList(item, event.target.checked)} /> ); } diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx index 5a3ff71..489c834 100644 --- a/src/features/host-org/pages/HostBdsPage.jsx +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -22,6 +22,12 @@ const bdColumns = [ render: (item) => , width: "minmax(220px, 1.25fr)", }, + { + key: "agencyCount", + label: "子 Agency", + render: (item) => {formatCount(item.agencyCount)}, + width: "minmax(110px, 0.65fr)", + }, { key: "role", label: "角色", width: "minmax(100px, 0.65fr)" }, { key: "salaryWallet", @@ -151,7 +157,7 @@ export function HostBdsPage() { columns={columns} context={{ page, regionOptions: page.regionOptions }} items={items} - minWidth="1440px" + minWidth="1550px" pagination={ total > 0 ? { @@ -271,3 +277,7 @@ function BDActions({ item, page }) { function bdUserLabel(item) { return item.displayUserId || item.username || item.userId; } + +function formatCount(value) { + return Number(value || 0).toLocaleString("zh-CN"); +} diff --git a/src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx b/src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx index 9c2f9f7..acd3b63 100644 --- a/src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx +++ b/src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { HostAgenciesPage } from "./HostAgenciesPage.jsx"; import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx"; import { HostBdsPage } from "./HostBdsPage.jsx"; @@ -28,12 +29,37 @@ test("agency list salary wallet header requests salary wallet sorting", () => { const changeSort = vi.fn(); vi.mocked(useHostAgenciesPage).mockReturnValue(agencyPageFixture({ changeSort })); - render(); + renderWithRouter(); fireEvent.click(screen.getByLabelText("按工资钱包降序排序")); expect(changeSort).toHaveBeenCalledWith("salary_wallet"); }); +test("agency host count opens filtered host list", () => { + vi.mocked(useHostAgenciesPage).mockReturnValue(agencyPageFixture()); + + const location = renderWithRouter(); + fireEvent.click(screen.getByRole("button", { name: "查看 Agency 9001 的 Host 列表" })); + + expect(location.current()).toBe("/host/hosts?agency_id=9001"); +}); + +test("closed agency status switch can reopen agency", () => { + const toggleAgencyStatusFromList = vi.fn(); + vi.mocked(useHostAgenciesPage).mockReturnValue( + agencyPageFixture({ + abilities: { canCreate: false, canDelete: false, canSalaryAdjust: false, canStatus: true }, + data: { items: [{ ...agencyFixture(), joinEnabled: false, status: "closed" }], page: 1, pageSize: 50, total: 1 }, + toggleAgencyStatusFromList, + }), + ); + + renderWithRouter(); + fireEvent.click(screen.getByRole("switch", { name: "启用 Agency" })); + + expect(toggleAgencyStatusFromList).toHaveBeenCalledWith(expect.objectContaining({ agencyId: "9001" }), true); +}); + test("bd leader list salary wallet header requests salary wallet sorting", () => { const changeSort = vi.fn(); vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "leader" })); @@ -54,6 +80,20 @@ test("bd list salary wallet header requests salary wallet sorting", () => { expect(changeSort).toHaveBeenCalledWith("salary_wallet"); }); +test("bd list renders child agency count", () => { + vi.mocked(useHostBdsPage).mockReturnValue( + bdPageFixture({ + data: { items: [{ ...bdFixture(), agencyCount: 7 }], page: 1, pageSize: 50, total: 1 }, + profileType: "bd", + }), + ); + + render(); + + expect(screen.getByText("子 Agency")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); +}); + test("host list salary wallet header requests salary wallet sorting", () => { const changeSort = vi.fn(); vi.mocked(useHostHostsPage).mockReturnValue(hostPageFixture({ changeSort })); @@ -96,6 +136,7 @@ function agencyPageFixture(patch = {}) { sortDirection: "", status: "", submitAgency: vi.fn(), + toggleAgencyStatusFromList: vi.fn(), toggleAgencyJoinEnabledFromList: vi.fn(), ...patch, }; @@ -184,6 +225,7 @@ function hostPageFixture(patch = {}) { function agencyFixture() { return { + activeHostCount: 12, agencyId: "9001", joinEnabled: true, name: "Agency One", @@ -200,6 +242,7 @@ function agencyFixture() { function bdFixture() { return { + agencyCount: 3, createdAtMs: 1760000000000, displayUserId: "160002", parentLeaderUserId: "3001", @@ -229,3 +272,19 @@ function hostFixture() { username: "host_user", }; } + +function renderWithRouter(ui) { + const location = { value: "" }; + function LocationProbe() { + const currentLocation = useLocation(); + location.value = `${currentLocation.pathname}${currentLocation.search}`; + return null; + } + render( + + {ui} + + , + ); + return { current: () => location.value }; +} diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index cc6a0eb..8ae23d6 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -235,6 +235,7 @@ export const API_OPERATIONS = { revokeResourceGrant: "revokeResourceGrant", search: "search", setAgencyJoinEnabled: "setAgencyJoinEnabled", + setAgencyStatus: "setAgencyStatus", setBDLeaderStatus: "setBDLeaderStatus", setBDStatus: "setBDStatus", setCoinSellerStatus: "setCoinSellerStatus", @@ -1850,6 +1851,13 @@ export const API_ENDPOINTS: Record = { permission: "agency:status", permissions: ["agency:status"] }, + setAgencyStatus: { + method: "PATCH", + operationId: API_OPERATIONS.setAgencyStatus, + path: "/v1/admin/agencies/{agency_id}/status", + permission: "agency:status", + permissions: ["agency:status"] + }, setBDLeaderStatus: { method: "PATCH", operationId: API_OPERATIONS.setBDLeaderStatus, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index d525351..b878728 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -708,6 +708,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/agencies/{agency_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setAgencyStatus"]; + trace?: never; + }; "/admin/agencies/{agency_id}/delete": { parameters: { query?: never; @@ -3479,6 +3495,7 @@ export interface components { }; Agency: { agencyId: number; + activeHostCount?: number; createdAtMs?: number; createdByUserId?: number; joinEnabled?: boolean; @@ -3510,6 +3527,7 @@ export interface components { targetUserId: string; }; BDProfile: { + agencyCount?: number; createdAtMs?: number; createdByUserId?: number; parentLeaderUserId?: string; @@ -4706,6 +4724,20 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + setAgencyStatus: { + parameters: { + query?: never; + header?: never; + path: { + agency_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; deleteAgency: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index f5e78f6..f96bf61 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -568,6 +568,7 @@ export interface AppVersionPayload { } export interface BDProfileDto { + agencyCount?: number; avatar?: string; createdAtMs?: number; createdByAvatar?: string; @@ -594,6 +595,7 @@ export interface BDProfileDto { } export interface AgencyDto { + activeHostCount?: number; agencyId: number; createdAtMs?: number; createdByUserId?: number; @@ -1488,6 +1490,10 @@ export interface AgencyJoinEnabledPayload extends HostCommandPayload { joinEnabled: boolean; } +export interface AgencyStatusPayload extends HostCommandPayload { + status: string; +} + export interface CountryDto { countryCode: string; countryDisplayName?: string;