支付和新app
This commit is contained in:
parent
64cfa472b8
commit
e25258f233
@ -622,6 +622,55 @@
|
||||
"x-permissions": ["seven-day-checkin:update"]
|
||||
}
|
||||
},
|
||||
"/admin/managers": {
|
||||
"get": {
|
||||
"operationId": "listManagers",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Page"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/PageSize"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/Keyword"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/Status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/RegionId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ManagerPageResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "bd:view",
|
||||
"x-permissions": ["bd:view"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createManager",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateManagerPayload"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"$ref": "#/components/responses/ManagerResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "bd:create",
|
||||
"x-permissions": ["bd:create"]
|
||||
}
|
||||
},
|
||||
"/admin/agencies": {
|
||||
"get": {
|
||||
"operationId": "listAgencies",
|
||||
@ -2477,6 +2526,18 @@
|
||||
"x-permissions": ["payment-third-party:update"]
|
||||
}
|
||||
},
|
||||
"/admin/payment/third-party-rates/sync": {
|
||||
"post": {
|
||||
"operationId": "syncThirdPartyPaymentRates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "payment-third-party:update",
|
||||
"x-permissions": ["payment-third-party:update"]
|
||||
}
|
||||
},
|
||||
"/admin/regions": {
|
||||
"get": {
|
||||
"operationId": "listRegions",
|
||||
@ -4283,6 +4344,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ManagerPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseManagerPage"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ManagerResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseManager"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BDProfilePageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -4476,6 +4557,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiPageManager": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Manager"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiPageBDProfile": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
@ -4590,6 +4692,36 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseManagerPage": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ApiPageManager"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseManager": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseBDProfilePage": {
|
||||
"allOf": [
|
||||
{
|
||||
@ -4908,6 +5040,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Manager": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
"properties": {
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"bdLeaderCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"createdAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"contact": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayUserId": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastInvitedAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"regionId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"regionName": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "disabled"]
|
||||
},
|
||||
"updatedAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateManagerPayload": {
|
||||
"type": "object",
|
||||
"required": ["targetUserId"],
|
||||
"properties": {
|
||||
"contact": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"targetUserId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BDProfile": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
|
||||
@ -34,7 +34,7 @@ describe("navigation menu helpers", () => {
|
||||
test("keeps only fallback menu items allowed by permissions", () => {
|
||||
const items = filterNavigationByPermission(
|
||||
fallbackNavigation,
|
||||
(code) => code === "agency:view" || code === "host:view",
|
||||
(code) => code === "agency:view" || code === "bd:view" || code === "host:view",
|
||||
);
|
||||
|
||||
expect(flattenCodes(items)).not.toContain("geo");
|
||||
@ -42,7 +42,8 @@ describe("navigation menu helpers", () => {
|
||||
expect(flattenCodes(items)).toContain("host-org-managers");
|
||||
expect(flattenCodes(items)).toContain("host-org-agencies");
|
||||
expect(flattenCodes(items)).toContain("host-org-hosts");
|
||||
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
||||
expect(flattenCodes(items)).toContain("host-org-bd-leaders");
|
||||
expect(flattenCodes(items)).toContain("host-org-bds");
|
||||
});
|
||||
|
||||
test("places country and region pages under geo menu", () => {
|
||||
@ -77,7 +78,10 @@ describe("navigation menu helpers", () => {
|
||||
label: "团队管理",
|
||||
},
|
||||
];
|
||||
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view");
|
||||
const fallbackMenus = filterNavigationByPermission(
|
||||
fallbackNavigation,
|
||||
(code) => code === "agency:view" || code === "bd:view",
|
||||
);
|
||||
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
|
||||
|
||||
expect(flattenCodes(merged)).toContain("host-org-agencies");
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
createBDLeader,
|
||||
createCountry,
|
||||
createCoinSeller,
|
||||
createManager,
|
||||
createRegion,
|
||||
creditCoinSellerStock,
|
||||
debitCoinSellerStock,
|
||||
@ -14,6 +15,7 @@ import {
|
||||
listCoinSellers,
|
||||
listCountries,
|
||||
listHosts,
|
||||
listManagers,
|
||||
listRegions,
|
||||
replaceRegionCountries,
|
||||
setCoinSellerStatus,
|
||||
@ -51,6 +53,14 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
sort_by: "salary_wallet",
|
||||
sort_direction: "desc",
|
||||
});
|
||||
await listManagers({
|
||||
keyword: "manager",
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
region_id: 7,
|
||||
status: "active",
|
||||
});
|
||||
await createManager({ contact: "+63", targetUserId: "1003" });
|
||||
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
||||
await listCoinSellers({
|
||||
page: 1,
|
||||
@ -86,15 +96,17 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
|
||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
|
||||
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
|
||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4];
|
||||
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
|
||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
|
||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10];
|
||||
const [managerUrl, managerInit] = vi.mocked(fetch).mock.calls[2];
|
||||
const [managerCreateUrl, managerCreateInit] = vi.mocked(fetch).mock.calls[3];
|
||||
const [hostUrl] = vi.mocked(fetch).mock.calls[4];
|
||||
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[5];
|
||||
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[7];
|
||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[8];
|
||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[10];
|
||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[11];
|
||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[12];
|
||||
|
||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||
@ -107,6 +119,15 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||
expect(String(agencyUrl)).toContain("sort_by=salary_wallet");
|
||||
expect(String(agencyUrl)).toContain("sort_direction=desc");
|
||||
expect(String(managerUrl)).toContain("/api/v1/admin/managers?");
|
||||
expect(String(managerUrl)).toContain("keyword=manager");
|
||||
expect(String(managerUrl)).toContain("region_id=7");
|
||||
expect(String(managerUrl)).toContain("status=active");
|
||||
expect(String(managerUrl)).not.toContain("parent_bd_user_id");
|
||||
expect(managerInit?.method).toBe("GET");
|
||||
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
|
||||
expect(managerCreateInit?.method).toBe("POST");
|
||||
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ contact: "+63", targetUserId: "1003" });
|
||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||
expect(String(hostUrl)).toContain("agency_id=41");
|
||||
expect(String(hostUrl)).toContain("sort_by=diamond");
|
||||
|
||||
@ -25,9 +25,11 @@ import type {
|
||||
CreateBDLeaderPayload,
|
||||
CreateBDPayload,
|
||||
CreateCoinSellerPayload,
|
||||
CreateManagerPayload,
|
||||
EntityId,
|
||||
HostCommandPayload,
|
||||
HostProfileDto,
|
||||
ManagerDto,
|
||||
PageQuery,
|
||||
RegionCountriesPayload,
|
||||
RegionDto,
|
||||
@ -168,6 +170,22 @@ export function listAgencies(query: PageQuery = {}): Promise<ApiPage<AgencyDto>>
|
||||
});
|
||||
}
|
||||
|
||||
export function listManagers(query: PageQuery = {}): Promise<ApiPage<ManagerDto>> {
|
||||
const endpoint = API_ENDPOINTS.listManagers;
|
||||
return apiRequest<ApiPage<ManagerDto>>(apiEndpointPath(API_OPERATIONS.listManagers), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function createManager(payload: CreateManagerPayload): Promise<ManagerDto> {
|
||||
const endpoint = API_ENDPOINTS.createManager;
|
||||
return apiRequest<ManagerDto, CreateManagerPayload>(apiEndpointPath(API_OPERATIONS.createManager), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
|
||||
const endpoint = API_ENDPOINTS.listHosts;
|
||||
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
|
||||
|
||||
@ -22,6 +22,12 @@ export const bdStatusFilters = [
|
||||
["disabled", "停用"]
|
||||
];
|
||||
|
||||
export const managerStatusFilters = [
|
||||
["", "全部"],
|
||||
["active", "启用"],
|
||||
["disabled", "停用"]
|
||||
];
|
||||
|
||||
export const hostStatusFilters = [
|
||||
["", "全部"],
|
||||
["active", "启用"],
|
||||
|
||||
@ -1,31 +1,34 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { createManager, listManagers } from "@/features/host-org/api";
|
||||
import { useBDAbilities } from "@/features/host-org/permissions.js";
|
||||
import { createManagerSchema } from "@/features/host-org/schema";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { closeAgency, listAgencies } from "@/features/host-org/api";
|
||||
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyManagerForm = () => ({ contact: "", targetUserId: "" });
|
||||
|
||||
export function useHostManagersPage() {
|
||||
const abilities = useAgencyAbilities();
|
||||
const abilities = useBDAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [managerForm, setManagerForm] = useState(emptyManagerForm);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
keyword: query,
|
||||
parent_bd_user_id: parentBdUserId,
|
||||
region_id: regionId,
|
||||
status,
|
||||
}),
|
||||
[parentBdUserId, query, regionId, status],
|
||||
[query, regionId, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
@ -34,7 +37,7 @@ export function useHostManagersPage() {
|
||||
reload,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载经理列表失败",
|
||||
fetcher: listAgencies,
|
||||
fetcher: listManagers,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
@ -56,68 +59,58 @@ export function useHostManagersPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeParentBdUserId = (value) => {
|
||||
setParentBdUserId(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentBdUserId("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const closeManagerAgency = async (agency) => {
|
||||
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
|
||||
return;
|
||||
}
|
||||
await runAction(`manager-status-${agency.agencyId}`, "经理已停用", async () => {
|
||||
await closeAgency(agency.agencyId, {
|
||||
commandId: makeCommandId("manager-close"),
|
||||
reason: "close manager agency from list switch",
|
||||
});
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
const submitManager = async (event) => {
|
||||
event.preventDefault();
|
||||
setLoadingAction("manager-create");
|
||||
try {
|
||||
await submitter();
|
||||
showToast(successMessage, "success");
|
||||
const payload = parseForm(createManagerSchema, managerForm);
|
||||
await createManager(payload);
|
||||
setManagerForm(emptyManagerForm());
|
||||
setActiveAction("");
|
||||
showToast("经理已添加", "success");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
showToast(err.message || "添加经理失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setManagerForm(emptyManagerForm());
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
changeParentBdUserId,
|
||||
activeAction,
|
||||
changeQuery,
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
closeManagerAgency,
|
||||
closeAction,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
loadingAction,
|
||||
loading,
|
||||
loadingRegions,
|
||||
managerForm,
|
||||
openManagerForm: () => setActiveAction("manager"),
|
||||
page,
|
||||
parentBdUserId,
|
||||
query,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
resetFilters,
|
||||
setManagerForm,
|
||||
setPage,
|
||||
status,
|
||||
submitManager,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandId(prefix) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
@ -1,55 +1,53 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { managerStatusFilters, statusLabels } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
const managerColumns = [
|
||||
{
|
||||
key: "ownerUserId",
|
||||
key: "userId",
|
||||
label: "经理",
|
||||
render: (item) => <ManagerUser item={item} />,
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{
|
||||
key: "agency",
|
||||
label: "Agency",
|
||||
render: (item) => <HostOrgEntity id={item.agencyId} name={item.name} prefix="Agency " />,
|
||||
width: "minmax(190px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "parentBdUserId",
|
||||
label: "上级 BD",
|
||||
render: (item) => <ParentBD item={item} />,
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
},
|
||||
{
|
||||
key: "regionId",
|
||||
label: "区域",
|
||||
render: (item, _index, context) => hostOrgRegionName(item, context?.regionOptions),
|
||||
width: "minmax(130px, 0.75fr)",
|
||||
},
|
||||
{
|
||||
key: "contact",
|
||||
label: "联系方式",
|
||||
render: (item) => item.contact || "-",
|
||||
width: "minmax(150px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item, _index, context) => <ManagerStatusSwitch item={item} page={context?.page} />,
|
||||
render: (item) => <ManagerStatus item={item} />,
|
||||
width: "minmax(90px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "joinEnabled",
|
||||
label: "入会",
|
||||
render: (item) => (
|
||||
<span className={item.joinEnabled ? styles.positive : styles.negative}>
|
||||
{item.joinEnabled ? "允许" : "禁止"}
|
||||
</span>
|
||||
),
|
||||
width: "minmax(80px, 0.6fr)",
|
||||
key: "bdLeaderCount",
|
||||
label: "BD Leader/Admin",
|
||||
render: (item) => item.bdLeaderCount || 0,
|
||||
width: "minmax(140px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "lastInvitedAtMs",
|
||||
label: "最近邀请",
|
||||
render: (item) => formatMillis(item.lastInvitedAtMs),
|
||||
width: "minmax(170px, 1fr)",
|
||||
},
|
||||
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
@ -60,34 +58,30 @@ const managerColumns = [
|
||||
|
||||
export function HostManagersPage() {
|
||||
const page = useHostManagersPage();
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const toolbarActions = [
|
||||
page.abilities.canCreate
|
||||
? { icon: <Add fontSize="small" />, label: "添加经理", onClick: page.openManagerForm, variant: "primary" }
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const columns = managerColumns.map((column) => {
|
||||
if (column.key === "ownerUserId") {
|
||||
if (column.key === "userId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索经理、Agency、用户",
|
||||
placeholder: "搜索经理",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "parentBdUserId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索上级 BD 用户 ID",
|
||||
value: page.parentBdUserId,
|
||||
onChange: page.changeParentBdUserId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: agencyStatusFilters,
|
||||
options: managerStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
@ -100,13 +94,14 @@ export function HostManagersPage() {
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar leadingActions={toolbarActions} />
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1250px"
|
||||
minWidth="1240px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -117,11 +112,35 @@ export function HostManagersPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => `manager-${item.ownerUserId || item.agencyId}`}
|
||||
rowKey={(item) => `manager-${item.userId}`}
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
<HostOrgActionModal
|
||||
disabled={createDisabled}
|
||||
loading={page.loadingAction === "manager-create"}
|
||||
open={page.activeAction === "manager"}
|
||||
sectionTitle="经理信息"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitManager}
|
||||
title="添加经理"
|
||||
>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="用户短 ID"
|
||||
required
|
||||
type="number"
|
||||
value={page.managerForm.targetUserId}
|
||||
onChange={(event) => page.setManagerForm({ ...page.managerForm, targetUserId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
label="联系方式"
|
||||
value={page.managerForm.contact}
|
||||
onChange={(event) => page.setManagerForm({ ...page.managerForm, contact: event.target.value })}
|
||||
/>
|
||||
</HostOrgActionModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -129,40 +148,19 @@ export function HostManagersPage() {
|
||||
function ManagerUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.ownerAvatar}
|
||||
displayUserId={item.ownerDisplayUserId}
|
||||
fallbackId={item.ownerUserId}
|
||||
username={item.ownerUsername}
|
||||
avatar={item.avatar}
|
||||
displayUserId={item.displayUserId}
|
||||
fallbackId={item.userId}
|
||||
username={item.username}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ParentBD({ item }) {
|
||||
if (!item.parentBdUserId) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<HostOrgPerson
|
||||
avatar={item.parentBdAvatar}
|
||||
displayUserId={item.parentBdDisplayUserId}
|
||||
fallbackId={item.parentBdUserId}
|
||||
username={item.parentBdUsername || "BD"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagerStatusSwitch({ item, page }) {
|
||||
function ManagerStatus({ item }) {
|
||||
const active = item.status === "active";
|
||||
return (
|
||||
<AdminSwitch
|
||||
checked={active}
|
||||
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `manager-status-${item.agencyId}`}
|
||||
inputProps={{ "aria-label": active ? "停用经理" : "经理已停用" }}
|
||||
onChange={(event) => {
|
||||
if (!event.target.checked) {
|
||||
page.closeManagerAgency(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className={active ? styles.positive : styles.negative}>
|
||||
{statusLabels[item.status] || item.status || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,12 +18,12 @@ export const hostOrgRoutes = [
|
||||
permission: PERMISSIONS.regionView,
|
||||
},
|
||||
{
|
||||
label: "Manager列表",
|
||||
label: "经理列表",
|
||||
loader: () => import("./pages/HostManagersPage.jsx").then((module) => module.HostManagersPage),
|
||||
menuCode: MENU_CODES.hostOrgManagers,
|
||||
pageKey: "host-org-managers",
|
||||
path: "/host/managers",
|
||||
permission: PERMISSIONS.agencyView,
|
||||
permission: PERMISSIONS.bdView,
|
||||
},
|
||||
{
|
||||
label: "Agency 列表",
|
||||
|
||||
@ -105,6 +105,11 @@ export const createCoinSellerSchema = commandContactSchema.extend({
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const createManagerSchema = z.object({
|
||||
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
||||
targetUserId: userIdSchema,
|
||||
});
|
||||
|
||||
export const coinSellerStatusSchema = commandContactSchema.extend({
|
||||
status: z.enum(["active", "disabled"]),
|
||||
targetUserId: entityIdSchema,
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
RechargeProductPayload,
|
||||
ThirdPartyPaymentChannelDto,
|
||||
ThirdPartyPaymentMethodDto,
|
||||
ThirdPartyPaymentRateSyncDto,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listRechargeBills(query: PageQuery = {}): Promise<ApiPage<RechargeBillDto>> {
|
||||
@ -97,3 +98,10 @@ export function updateThirdPartyPaymentRate(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function syncThirdPartyPaymentRates(): Promise<ThirdPartyPaymentRateSyncDto> {
|
||||
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
|
||||
return apiRequest<ThirdPartyPaymentRateSyncDto>(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
listThirdPartyPaymentChannels,
|
||||
setThirdPartyPaymentMethodStatus,
|
||||
syncThirdPartyPaymentRates,
|
||||
updateThirdPartyPaymentRate,
|
||||
} from "@/features/payment/api";
|
||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||
@ -106,6 +107,25 @@ export function useThirdPartyPaymentPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const syncRates = async () => {
|
||||
// 汇率同步只触发服务端动作;汇率源、失败切换和写库都在 admin-server 完成,前端不接收可篡改的汇率输入。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction("sync-rates");
|
||||
try {
|
||||
const summary = await syncThirdPartyPaymentRates();
|
||||
const fresh = await result.reload();
|
||||
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
|
||||
const suffix = summary?.sourceNames?.length ? `(${summary.sourceNames.join("、")})` : "";
|
||||
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "汇率同步失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeChannel,
|
||||
@ -118,7 +138,17 @@ export function useThirdPartyPaymentPage() {
|
||||
reload: result.reload,
|
||||
saveRate,
|
||||
setRateDraft,
|
||||
syncRates,
|
||||
toggleChannel,
|
||||
toggleMethod,
|
||||
};
|
||||
}
|
||||
|
||||
function rateDraftsFromChannels(channels) {
|
||||
return channels.reduce((drafts, channel) => {
|
||||
(channel.methods || []).forEach((method) => {
|
||||
drafts[String(method.methodId)] = method.usdToCurrencyRate || "";
|
||||
});
|
||||
return drafts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@ -2,9 +2,13 @@ import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
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 { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
|
||||
import { ThirdPartyPaymentContent } from "@/features/payment/pages/ThirdPartyPaymentPage.jsx";
|
||||
import styles from "@/features/payment/payment.module.css";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
@ -49,146 +53,173 @@ const audienceOptions = [
|
||||
|
||||
export function RechargeProductConfigPage() {
|
||||
const page = useRechargeProductsPage();
|
||||
const [activeProductTab, setActiveProductTab] = useState("google");
|
||||
const googleProductPlatform = "android";
|
||||
const { platform, setPlatform } = page;
|
||||
const items = page.data?.items || [];
|
||||
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
|
||||
const regionValues = useMemo(() => page.regionOptions.map((option) => option.value), [page.regionOptions]);
|
||||
const columns = useMemo(() => productColumns(page, regionLabels), [page, regionLabels]);
|
||||
const columns = useMemo(() => productColumns(page, regionLabels, true), [page, regionLabels]);
|
||||
const saving = page.loadingAction === "create" || page.loadingAction === "edit";
|
||||
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
|
||||
const openCreate = () => {
|
||||
page.openCreate();
|
||||
page.setForm({ platform: googleProductPlatform });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProductTab === "google" && platform !== googleProductPlatform) {
|
||||
setPlatform(googleProductPlatform);
|
||||
}
|
||||
}, [activeProductTab, googleProductPlatform, platform, setPlatform]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateProduct ? (
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openCreate}
|
||||
>
|
||||
新增内购
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1180px"
|
||||
pagination={{
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
total: page.data?.total || 0,
|
||||
onPageChange: page.setPage,
|
||||
}}
|
||||
rowKey={(item) => item.productId}
|
||||
<div className={styles.paymentTabsBar}>
|
||||
<Tabs value={activeProductTab} onChange={(_, value) => setActiveProductTab(value)}>
|
||||
<Tab label="Google" value="google" />
|
||||
<Tab label="三方" value="third_party" />
|
||||
</Tabs>
|
||||
</div>
|
||||
{activeProductTab === "google" ? (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canCreateProduct ? (
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增 Google 商品
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1180px"
|
||||
pagination={{
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
total: page.data?.total || 0,
|
||||
onPageChange: page.setPage,
|
||||
}}
|
||||
rowKey={(item) => item.productId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</>
|
||||
) : (
|
||||
<ThirdPartyPaymentContent embedded />
|
||||
)}
|
||||
|
||||
<AdminFormDialog
|
||||
loading={saving}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="compact"
|
||||
submitDisabled={!canSubmit || saving}
|
||||
title={page.editingItem ? "编辑内购配置" : "新增内购配置"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitProduct}
|
||||
>
|
||||
<AdminFormSection title="商品信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
{activeProductTab === "google" ? (
|
||||
<AdminFormDialog
|
||||
loading={saving}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="compact"
|
||||
submitDisabled={!canSubmit || saving}
|
||||
title={page.editingItem ? "编辑 Google 商品" : "新增 Google 商品"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitProduct}
|
||||
>
|
||||
<AdminFormSection title="商品信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="产品名称"
|
||||
required
|
||||
value={page.form.productName}
|
||||
onChange={(event) => page.setForm({ productName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="web">Web</MenuItem>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="用户类型"
|
||||
required
|
||||
select
|
||||
value={page.form.audienceType}
|
||||
onChange={(event) => page.setForm({ audienceType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="normal">普通用户</MenuItem>
|
||||
<MenuItem value="coin_seller">币商</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格配置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormAmountField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ inputMode: "decimal" }}
|
||||
label="金额USDT"
|
||||
required
|
||||
unit="USDT"
|
||||
value={page.form.amountUsdt}
|
||||
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
label="获得金币数"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.coinAmount}
|
||||
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<MultiValueAutocomplete
|
||||
disabled={!canSubmit}
|
||||
label="产品名称"
|
||||
label="支持区域"
|
||||
labels={regionLabels}
|
||||
loading={page.loadingRegions}
|
||||
options={regionValues}
|
||||
required
|
||||
value={page.form.productName}
|
||||
onChange={(event) => page.setForm({ productName: event.target.value })}
|
||||
value={page.form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ regionIds })}
|
||||
/>
|
||||
<TextField
|
||||
<AdminFormSwitchField
|
||||
checked={Boolean(page.form.enabled)}
|
||||
checkedLabel="上架"
|
||||
disabled={!canSubmit}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="web">Web</MenuItem>
|
||||
<MenuItem value="android">Android</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="用户类型"
|
||||
required
|
||||
select
|
||||
value={page.form.audienceType}
|
||||
onChange={(event) => page.setForm({ audienceType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="normal">普通用户</MenuItem>
|
||||
<MenuItem value="coin_seller">币商</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格配置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormAmountField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ inputMode: "decimal" }}
|
||||
label="金额USDT"
|
||||
required
|
||||
unit="USDT"
|
||||
value={page.form.amountUsdt}
|
||||
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
|
||||
label="是否上架"
|
||||
uncheckedLabel="下架"
|
||||
onChange={(checked) => page.setForm({ enabled: checked })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="描述">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
label="获得金币数"
|
||||
required
|
||||
type="number"
|
||||
value={page.form.coinAmount}
|
||||
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<MultiValueAutocomplete
|
||||
disabled={!canSubmit}
|
||||
label="支持区域"
|
||||
labels={regionLabels}
|
||||
loading={page.loadingRegions}
|
||||
options={regionValues}
|
||||
required
|
||||
value={page.form.regionIds}
|
||||
onChange={(regionIds) => page.setForm({ regionIds })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={Boolean(page.form.enabled)}
|
||||
checkedLabel="上架"
|
||||
disabled={!canSubmit}
|
||||
label="是否上架"
|
||||
uncheckedLabel="下架"
|
||||
onChange={(checked) => page.setForm({ enabled: checked })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="描述">
|
||||
<TextField
|
||||
disabled={!canSubmit}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
) : null}
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function productColumns(page, regionLabels) {
|
||||
function productColumns(page, regionLabels, lockedPlatform = false) {
|
||||
const columns = [
|
||||
{
|
||||
key: "product",
|
||||
@ -216,12 +247,14 @@ function productColumns(page, regionLabels) {
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
render: (item) => platformLabel(item.platform),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: platformOptions,
|
||||
placeholder: "搜索平台",
|
||||
value: page.platform,
|
||||
onChange: page.setPlatform,
|
||||
}),
|
||||
filter: lockedPlatform
|
||||
? undefined
|
||||
: createOptionsColumnFilter({
|
||||
options: platformOptions,
|
||||
placeholder: "搜索平台",
|
||||
value: page.platform,
|
||||
onChange: page.setPlatform,
|
||||
}),
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -12,22 +13,37 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import styles from "@/features/payment/payment.module.css";
|
||||
|
||||
export function ThirdPartyPaymentPage() {
|
||||
return <ThirdPartyPaymentContent showProductButton />;
|
||||
}
|
||||
|
||||
export function ThirdPartyPaymentContent({ embedded = false, showProductButton = false }) {
|
||||
const page = useThirdPartyPaymentPage();
|
||||
const navigate = useNavigate();
|
||||
// 国家分组只影响展示密度,不改变后端返回的可开关粒度;每个 method 仍独立保存状态和汇率。
|
||||
const groupedMethods = useMemo(() => groupMethods(page.activeChannel?.methods || []), [page.activeChannel]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
const content = (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
startIcon={<SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={() => navigate("/payment/recharge-products")}
|
||||
>
|
||||
三方支付内购配置
|
||||
</Button>
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
|
||||
startIcon={<SyncOutlined fontSize="small" />}
|
||||
onClick={page.syncRates}
|
||||
>
|
||||
支付汇率全球同步
|
||||
</Button>
|
||||
{showProductButton ? (
|
||||
<Button
|
||||
startIcon={<SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={() => navigate("/payment/recharge-products")}
|
||||
>
|
||||
支付内购商品
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
@ -82,9 +98,12 @@ export function ThirdPartyPaymentPage() {
|
||||
</div>
|
||||
{group.methods.map((method) => (
|
||||
<div key={method.methodId} className={styles.methodRow}>
|
||||
<span className={styles.methodName}>
|
||||
<strong>{method.methodName || method.payWay || "-"}</strong>
|
||||
<small>{method.payWay || "-"}</small>
|
||||
<span className={styles.methodIdentity}>
|
||||
<MethodLogo method={method} />
|
||||
<span className={styles.methodName}>
|
||||
<strong>{method.methodName || method.payWay || "-"}</strong>
|
||||
<small>{method.payWay || "-"}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span>{method.payType || "-"}</span>
|
||||
<span>{method.currencyCode || "-"}</span>
|
||||
@ -117,9 +136,10 @@ export function ThirdPartyPaymentPage() {
|
||||
page.loadingAction === `status:${method.methodId}`
|
||||
}
|
||||
inputProps={{
|
||||
"aria-label": method.status === "active"
|
||||
? "关闭支付方式"
|
||||
: "打开支付方式",
|
||||
"aria-label":
|
||||
method.status === "active"
|
||||
? "关闭支付方式"
|
||||
: "打开支付方式",
|
||||
}}
|
||||
uncheckedLabel="关"
|
||||
onChange={(event) =>
|
||||
@ -139,14 +159,28 @@ export function ThirdPartyPaymentPage() {
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</AdminListPage>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className={styles.paymentEmbedded}>{content}</div>;
|
||||
}
|
||||
|
||||
return <AdminListPage>{content}</AdminListPage>;
|
||||
}
|
||||
|
||||
function StatusPill({ active }) {
|
||||
return <span className={active ? styles.statusActive : styles.statusDisabled}>{active ? "已启用" : "已停用"}</span>;
|
||||
}
|
||||
|
||||
function MethodLogo({ method }) {
|
||||
const label = String(method.methodName || method.payType || method.payWay || "Pay").slice(0, 2).toUpperCase();
|
||||
if (method.logoUrl) {
|
||||
return <img alt="" className={styles.methodLogo} src={method.logoUrl} />;
|
||||
}
|
||||
return <span className={styles.methodLogoFallback}>{label}</span>;
|
||||
}
|
||||
|
||||
function groupMethods(methods) {
|
||||
// GLOBAL 是三方支付的跨国家方式,放在最前面,国家方式再按标题排序,方便运营先处理全局支付。
|
||||
const groups = new Map();
|
||||
|
||||
@ -55,6 +55,29 @@
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.paymentTabsBar {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.paymentEmbedded {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.thirdPartyLayout {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@ -207,6 +230,37 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.methodIdentity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.methodLogo,
|
||||
.methodLogoFallback {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.methodLogo {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.methodLogoFallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.methodName strong,
|
||||
.methodName small {
|
||||
overflow: hidden;
|
||||
|
||||
@ -18,7 +18,7 @@ export const paymentRoutes = [
|
||||
permission: PERMISSIONS.paymentThirdPartyView,
|
||||
},
|
||||
{
|
||||
label: "三方支付内购配置",
|
||||
label: "支付内购商品",
|
||||
loader: () =>
|
||||
import("./pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
157
src/shared/api/generated/schema.d.ts
vendored
157
src/shared/api/generated/schema.d.ts
vendored
@ -532,6 +532,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/managers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listManagers"];
|
||||
put?: never;
|
||||
post: operations["createManager"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/agencies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1188,6 +1204,22 @@ export interface paths {
|
||||
patch: operations["updateDiceConfig"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["adjustDicePool"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1716,6 +1748,22 @@ export interface paths {
|
||||
patch: operations["updateThirdPartyPaymentRate"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/payment/third-party-rates/sync": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["syncThirdPartyPaymentRates"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/regions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -2910,6 +2958,12 @@ export interface components {
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
ApiPageManager: {
|
||||
items: components["schemas"]["Manager"][];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
ApiPageBDProfile: {
|
||||
items: components["schemas"]["BDProfile"][];
|
||||
page: number;
|
||||
@ -2940,6 +2994,12 @@ export interface components {
|
||||
ApiResponseAgencyPage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageAgency"];
|
||||
};
|
||||
ApiResponseManagerPage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageManager"];
|
||||
};
|
||||
ApiResponseManager: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["Manager"];
|
||||
};
|
||||
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ApiPageBDProfile"];
|
||||
};
|
||||
@ -3011,6 +3071,25 @@ export interface components {
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
};
|
||||
Manager: {
|
||||
avatar?: string;
|
||||
bdLeaderCount?: number;
|
||||
createdAtMs?: number;
|
||||
contact?: string;
|
||||
displayUserId?: string;
|
||||
lastInvitedAtMs?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
/** @enum {string} */
|
||||
status?: "active" | "disabled";
|
||||
updatedAtMs?: number;
|
||||
userId: string;
|
||||
username?: string;
|
||||
};
|
||||
CreateManagerPayload: {
|
||||
contact?: string;
|
||||
targetUserId: string;
|
||||
};
|
||||
BDProfile: {
|
||||
createdAtMs?: number;
|
||||
createdByUserId?: number;
|
||||
@ -3088,6 +3167,24 @@ export interface components {
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
ManagerPageResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseManagerPage"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
ManagerResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseManager"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
BDProfilePageResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@ -3901,6 +3998,40 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listManagers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: components["parameters"]["Page"];
|
||||
page_size?: components["parameters"]["PageSize"];
|
||||
keyword?: components["parameters"]["Keyword"];
|
||||
status?: components["parameters"]["Status"];
|
||||
region_id?: components["parameters"]["RegionId"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ManagerPageResponse"];
|
||||
};
|
||||
};
|
||||
createManager: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateManagerPayload"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: components["responses"]["ManagerResponse"];
|
||||
};
|
||||
};
|
||||
listAgencies: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@ -4777,6 +4908,20 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
adjustDicePool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
game_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listDiceRobots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -5350,6 +5495,18 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
syncThirdPartyPaymentRates: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listRegions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -452,6 +452,7 @@ export interface ThirdPartyPaymentMethodDto {
|
||||
countryCode?: string;
|
||||
countryName?: string;
|
||||
currencyCode?: string;
|
||||
logoUrl?: string;
|
||||
methodId: number;
|
||||
methodName?: string;
|
||||
payType?: string;
|
||||
@ -472,6 +473,21 @@ export interface ThirdPartyPaymentChannelDto {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ThirdPartyPaymentRateSyncDto {
|
||||
missingCurrencies?: string[];
|
||||
rates?: Record<string, string>;
|
||||
skippedCount?: number;
|
||||
sourceNames?: string[];
|
||||
sources?: Array<{
|
||||
currencies?: string[];
|
||||
error?: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
url?: string;
|
||||
}>;
|
||||
updatedCount: number;
|
||||
}
|
||||
|
||||
export interface AppVersionDto {
|
||||
appCode?: string;
|
||||
buildNumber: number;
|
||||
@ -542,6 +558,26 @@ export interface AgencyDto {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface ManagerDto {
|
||||
avatar?: string;
|
||||
bdLeaderCount?: number;
|
||||
contact?: string;
|
||||
createdAtMs?: number;
|
||||
displayUserId?: string;
|
||||
lastInvitedAtMs?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
status?: "active" | "disabled";
|
||||
updatedAtMs?: number;
|
||||
userId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface CreateManagerPayload {
|
||||
contact?: string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface HostProfileDto {
|
||||
avatar?: string;
|
||||
createdAtMs?: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user