即构游戏以及bd相关修复

This commit is contained in:
zhx 2026-06-29 17:47:33 +08:00
parent a95330857c
commit c08f9e7a83
13 changed files with 265 additions and 22 deletions

View File

@ -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"
},

View File

@ -60,6 +60,7 @@ const writableBridgeAdapterTypes = new Set([
"baishun_v1",
"vivagames_v1",
"reyou_v1",
"zgame_v1",
]);
const emptyCatalog = { items: [], pageSize: 50 };

View File

@ -51,6 +51,7 @@ const adapterTypeOptions = [
["baishun_v1", "百顺 BAISHUN V1"],
["vivagames_v1", "VIVAGAMES V1"],
["reyou_v1", "热游 Reyou V1"],
["zgame_v1", "ZGame V1"],
];
const baseColumns = [

View File

@ -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<AgencyDto> {
const endpoint = API_ENDPOINTS.setAgencyStatus;
return apiRequest<AgencyDto, AgencyStatusPayload>(
apiEndpointPath(API_OPERATIONS.setAgencyStatus, { agency_id: agencyId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.setAgencyJoinEnabled;
return apiRequest<AgencyDto, AgencyJoinEnabledPayload>(

View File

@ -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];
}

View File

@ -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 {

View File

@ -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;

View File

@ -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) => <AgencyOwner item={item} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "activeHostCount",
label: "子 Host",
render: (item, _index, context) => <AgencyHostCount item={item} onOpen={context?.openAgencyHosts} />,
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: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
@ -158,9 +172,9 @@ export function HostAgenciesPage() {
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
context={{ openAgencyHosts, page, regionOptions: page.regionOptions }}
items={items}
minWidth="1340px"
minWidth="1450px"
pagination={
total > 0
? {
@ -251,18 +265,28 @@ function ParentBD({ item }) {
);
}
function AgencyHostCount({ item, onOpen }) {
const count = Number(item.activeHostCount ?? item.hostCount ?? 0);
return (
<button
aria-label={`查看 Agency ${item.agencyId} 的 Host 列表`}
className={styles.hostCountButton}
type="button"
onClick={() => onOpen?.(item)}
>
{count.toLocaleString("zh-CN")}
</button>
);
}
function AgencyStatusSwitch({ item, page }) {
const active = item.status === "active";
return (
<AdminSwitch
checked={active}
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `agency-status-${item.agencyId}`}
inputProps={{ "aria-label": active ? "关闭 Agency" : "Agency 已关闭" }}
onChange={(event) => {
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)}
/>
);
}

View File

@ -22,6 +22,12 @@ const bdColumns = [
render: (item) => <BDUser item={item} />,
width: "minmax(220px, 1.25fr)",
},
{
key: "agencyCount",
label: "子 Agency",
render: (item) => <span className={styles.inlineValue}>{formatCount(item.agencyCount)}</span>,
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");
}

View File

@ -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(<HostAgenciesPage />);
renderWithRouter(<HostAgenciesPage />);
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(<HostAgenciesPage />);
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(<HostAgenciesPage />);
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(<HostBdsPage />);
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(
<MemoryRouter initialEntries={["/host/agencies"]}>
{ui}
<LocationProbe />
</MemoryRouter>,
);
return { current: () => location.value };
}

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;

View File

@ -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;