支付和新app

This commit is contained in:
zhx 2026-06-15 19:17:13 +08:00
parent 64cfa472b8
commit e25258f233
18 changed files with 2906 additions and 2289 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,12 @@ export const bdStatusFilters = [
["disabled", "停用"]
];
export const managerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
];
export const hostStatusFilters = [
["", "全部"],
["active", "启用"],

View File

@ -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()}`;
}

View File

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

View File

@ -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 列表",

View File

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

View File

@ -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,
});
}

View File

@ -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;
}, {});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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