This commit is contained in:
zhx 2026-06-25 22:09:45 +08:00
parent 2125f81baf
commit c33fb469d9
9 changed files with 208 additions and 30 deletions

View File

@ -2739,6 +2739,33 @@
"x-permissions": ["payment-third-party:update"]
}
},
"/admin/payment/third-party-methods/sync": {
"post": {
"operationId": "syncThirdPartyPaymentMethods",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"providerCode": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-third-party:update",
"x-permissions": ["payment-third-party:update"]
}
},
"/admin/payment/third-party-rates/{method_id}": {
"patch": {
"operationId": "updateThirdPartyPaymentRate",

View File

@ -10,6 +10,8 @@ import type {
RechargeProductPayload,
TemporaryPaymentLinkDto,
ThirdPartyPaymentChannelDto,
ThirdPartyPaymentMethodSyncDto,
ThirdPartyPaymentMethodSyncPayload,
ThirdPartyPaymentMethodDto,
ThirdPartyPaymentRateSyncPayload,
ThirdPartyPaymentRateSyncDto,
@ -75,13 +77,10 @@ export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPayme
export function listTemporaryPaymentLinks(query: PageQuery = {}): Promise<ApiPage<TemporaryPaymentLinkDto>> {
const endpoint = API_ENDPOINTS.listTemporaryPaymentLinks;
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(
apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks),
{
method: endpoint.method,
query,
},
);
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks), {
method: endpoint.method,
query,
});
}
export function getTemporaryPaymentLink(orderId: EntityId): Promise<TemporaryPaymentLinkDto> {
@ -122,6 +121,19 @@ export function updateThirdPartyPaymentRate(
);
}
export function syncThirdPartyPaymentMethods(
payload: ThirdPartyPaymentMethodSyncPayload = { providerCode: "v5pay" },
): Promise<ThirdPartyPaymentMethodSyncDto> {
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentMethods;
return apiRequest<ThirdPartyPaymentMethodSyncDto, ThirdPartyPaymentMethodSyncPayload>(
apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentMethods),
{
body: payload,
method: endpoint.method,
},
);
}
export function syncThirdPartyPaymentRates(
payload: ThirdPartyPaymentRateSyncPayload = { markupPercent: 0 },
): Promise<ThirdPartyPaymentRateSyncDto> {

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import {
listThirdPartyPaymentChannels,
syncThirdPartyPaymentMethods,
setThirdPartyPaymentMethodStatus,
syncThirdPartyPaymentRates,
updateThirdPartyPaymentRate,
@ -90,6 +91,25 @@ export function useThirdPartyPaymentPage() {
setSyncRateDialogOpen(false);
};
const syncPaymentMethods = async (channel) => {
// 支付方式同步只开放给 V5PayMiFaPay 暂无可查询当前商户已开通方式的后端接口。
if (!abilities.canUpdateThirdParty || channel?.providerCode !== "v5pay") {
return;
}
const actionKey = `sync-methods:${channel.providerCode}`;
setLoadingAction(actionKey);
try {
const summary = await syncThirdPartyPaymentMethods({ providerCode: channel.providerCode });
const fresh = await result.reload();
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
showToast(paymentMethodSyncToast(summary), summary?.failedCountryCount ? "warning" : "success");
} catch (err) {
showToast(err.message || "同步支付方式失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleMethod = async (method, active) => {
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
if (!abilities.canUpdateThirdParty) {
@ -172,6 +192,7 @@ export function useThirdPartyPaymentPage() {
saveRate,
setRateDraft,
setSyncRateMarkupPercent,
syncPaymentMethods,
submitSyncRates,
syncRateDialogOpen,
syncRateForm,
@ -188,3 +209,13 @@ function rateDraftsFromChannels(channels) {
return drafts;
}, {});
}
function paymentMethodSyncToast(summary = {}) {
const created = summary.createdCount || 0;
const updated = summary.updatedCount || 0;
const failed = summary.failedCountryCount || 0;
const skipped = summary.skippedCount || 0;
const suffix = failed ? `,失败 ${failed} 个国家` : "";
const skippedSuffix = skipped ? `,跳过 ${skipped} 个方式` : "";
return `支付方式已同步:新增 ${created} 个,更新 ${updated}${skippedSuffix}${suffix}`;
}

View File

@ -53,7 +53,7 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
<div className={styles.thirdPartyLayout}>
<div className={styles.channelList}>
{page.channels.map((channel) => (
<button
<div
key={channel.providerCode}
className={[
styles.channelRow,
@ -63,22 +63,39 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
]
.filter(Boolean)
.join(" ")}
type="button"
onClick={() => page.toggleChannel(channel.providerCode)}
>
<span className={styles.channelExpandIcon}>
{page.expandedChannel === channel.providerCode ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
<span className={styles.channelText}>
<span>{channel.providerName || channel.providerCode}</span>
<span>{channel.providerCode}</span>
</span>
<StatusPill active={channel.status === "active"} />
</button>
<button
className={styles.channelSelectButton}
type="button"
onClick={() => page.toggleChannel(channel.providerCode)}
>
<span className={styles.channelExpandIcon}>
{page.expandedChannel === channel.providerCode ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
<span className={styles.channelText}>
<span>{channel.providerName || channel.providerCode}</span>
<span>{channel.providerCode}</span>
</span>
</button>
{channel.providerCode === "v5pay" ? (
<Button
disabled={
!page.abilities.canUpdateThirdParty ||
page.loadingAction === `sync-methods:${channel.providerCode}`
}
startIcon={<SyncOutlined fontSize="small" />}
onClick={() => page.syncPaymentMethods(channel)}
>
{page.loadingAction === `sync-methods:${channel.providerCode}`
? "同步中"
: "同步支付方式"}
</Button>
) : null}
</div>
))}
</div>
@ -196,12 +213,10 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
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();
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} />;
}

View File

@ -40,6 +40,23 @@ test("third-party payment rate dialog submits markup percent", () => {
expect(page.submitSyncRates).toHaveBeenCalledTimes(1);
});
test("third-party payment page shows v5pay method sync button instead of status text", () => {
const v5pay = { providerCode: "v5pay", providerName: "V5Pay", status: "active", methods: [] };
const page = pageFixture({
activeChannel: v5pay,
channels: [{ providerCode: "mifapay", providerName: "MiFaPay", status: "active", methods: [] }, v5pay],
syncPaymentMethods: vi.fn(),
});
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
renderPage();
expect(screen.queryByText("已启用")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "同步支付方式" }));
expect(page.syncPaymentMethods).toHaveBeenCalledWith(v5pay);
});
function renderPage() {
return render(
<MemoryRouter>
@ -66,6 +83,7 @@ function pageFixture(overrides = {}) {
saveRate: vi.fn(),
setRateDraft: vi.fn(),
setSyncRateMarkupPercent: vi.fn(),
syncPaymentMethods: vi.fn(),
submitSyncRates: vi.fn((event) => event.preventDefault()),
syncRateDialogOpen: false,
syncRateForm: { markupPercent: "0" },

View File

@ -131,9 +131,8 @@
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: 24px minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
text-align: left;
}
@ -143,6 +142,20 @@
background: var(--active-surface);
}
.channelSelectButton {
display: grid;
min-width: 0;
align-items: center;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
gap: var(--space-2);
grid-template-columns: 24px minmax(0, 1fr);
padding: 0;
text-align: left;
}
.channelExpandIcon {
display: inline-flex;
align-items: center;

View File

@ -242,6 +242,7 @@ export const API_OPERATIONS = {
startRobotRoom: "startRobotRoom",
stopRobotRoom: "stopRobotRoom",
syncPermissions: "syncPermissions",
syncThirdPartyPaymentMethods: "syncThirdPartyPaymentMethods",
syncThirdPartyPaymentRates: "syncThirdPartyPaymentRates",
updateAchievementDefinition: "updateAchievementDefinition",
updateAppVersion: "updateAppVersion",
@ -1892,6 +1893,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "permission:sync",
permissions: ["permission:sync"],
},
syncThirdPartyPaymentMethods: {
method: "POST",
operationId: API_OPERATIONS.syncThirdPartyPaymentMethods,
path: "/v1/admin/payment/third-party-methods/sync",
permission: "payment-third-party:update",
permissions: ["payment-third-party:update"],
},
syncThirdPartyPaymentRates: {
method: "POST",
operationId: API_OPERATIONS.syncThirdPartyPaymentRates,

View File

@ -1924,6 +1924,22 @@ export interface paths {
patch: operations["setThirdPartyPaymentMethodStatus"];
trace?: never;
};
"/admin/payment/third-party-methods/sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["syncThirdPartyPaymentMethods"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/third-party-rates/{method_id}": {
parameters: {
query?: never;
@ -6093,6 +6109,24 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
syncThirdPartyPaymentMethods: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
providerCode?: string;
};
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateThirdPartyPaymentRate: {
parameters: {
query?: never;

View File

@ -477,6 +477,26 @@ export interface ThirdPartyPaymentRateSyncPayload {
markupPercent: number;
}
export interface ThirdPartyPaymentMethodSyncPayload {
providerCode?: string;
}
export interface ThirdPartyPaymentMethodSyncDto {
createdCount: number;
failedCountryCount?: number;
fetchedCountryCount?: number;
issues?: Array<{
code?: string;
countryCode?: string;
currencyCode?: string;
message?: string;
}>;
providerCode?: string;
scannedCountryCount?: number;
skippedCount?: number;
updatedCount: number;
}
export interface TemporaryPaymentLinkDto {
appCode?: string;
audienceType?: string;