feat: drive visible payment methods from enabled providers

This commit is contained in:
IanShaw027
2026-04-21 23:17:45 +08:00
parent 54dc176725
commit b22d00e541
15 changed files with 609 additions and 506 deletions

View File

@@ -4757,6 +4757,7 @@ export default {
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
allowUserRefund: 'Allow User Refund',
enableConflict: '{method} already has an enabled provider instance: {provider}. Disable the existing instance before switching.',
},
balanceNotify: {
title: 'Balance Low Notification',
@@ -5612,6 +5613,7 @@ export default {
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.',
},
stripePay: 'Pay Now',
stripeSuccessProcessing: 'Payment successful, processing your order...',

View File

@@ -4921,6 +4921,7 @@ export default {
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
allowUserRefund: '允许用户退款',
enableConflict: '{method} 已有启用中的服务商实例:{provider}。请先停用现有实例后再启用或切换。',
},
balanceNotify: {
title: '余额不足提醒',
@@ -5800,6 +5801,7 @@ export default {
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
alipayMobileOpenHint: '请允许当前页面打开支付宝 App或改用系统浏览器重新发起支付。',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。',
},
stripePay: '立即支付',
stripeSuccessProcessing: '支付成功,正在处理订单...',

View File

@@ -4160,73 +4160,6 @@
</a>
</p>
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div
v-for="visibleMethod in paymentVisibleMethodCards"
:key="visibleMethod.key"
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
>
<div class="flex items-center justify-between">
<div>
<label
class="font-medium text-gray-900 dark:text-white"
>
{{
t("admin.settings.paymentVisibleMethods.methodLabel", {
title: visibleMethod.title,
})
}}
</label>
<p
class="mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t("admin.settings.paymentVisibleMethods.methodHint")
}}
</p>
</div>
<Toggle
:model-value="
getPaymentVisibleMethodEnabled(visibleMethod.key)
"
@update:model-value="
setPaymentVisibleMethodEnabled(
visibleMethod.key,
$event,
)
"
/>
</div>
<div class="mt-4">
<label class="input-label">
{{ t("admin.settings.paymentVisibleMethods.sourceLabel") }}
</label>
<Select
:model-value="
getPaymentVisibleMethodSource(visibleMethod.key)
"
:options="
getPaymentVisibleMethodSourceSelectOptions(
visibleMethod.key,
)
"
@update:model-value="
setPaymentVisibleMethodSource(
visibleMethod.key,
$event,
)
"
:placeholder="visibleMethod.key"
/>
<p class="mt-1.5 text-xs text-gray-400">
{{
t("admin.settings.paymentVisibleMethods.sourceHint")
}}
</p>
</div>
</div>
</div>
<!-- Row 5: Help image + text -->
<div class="grid grid-cols-2 gap-3">
<div>
@@ -4742,15 +4675,12 @@ import {
buildAuthSourceDefaultsState,
defaultWeChatConnectScopesForMode,
deriveWeChatConnectStoredMode,
getPaymentVisibleMethodSourceOptions,
normalizePaymentVisibleMethodSource,
normalizeDefaultSubscriptionSettings,
resolveWeChatConnectModeCapabilities,
} from "@/api/admin/settings";
import type {
AuthSourceDefaultsState,
AuthSourceType,
PaymentVisibleMethod,
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting,
@@ -4777,6 +4707,7 @@ import { useClipboard } from "@/composables/useClipboard";
import { extractApiErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores";
import { useAdminSettingsStore } from "@/stores/adminSettings";
import { normalizeVisibleMethod } from "@/components/payment/paymentFlow";
import {
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
@@ -4788,10 +4719,6 @@ const { t, locale } = useI18n();
const appStore = useAppStore();
const adminSettingsStore = useAdminSettingsStore();
function localText(zh: string, en: string): string {
return locale.value.startsWith("zh") ? zh : en;
}
type SettingsTab =
| "general"
| "security"
@@ -4908,10 +4835,6 @@ type SettingsForm = Omit<
wechat_connect_mobile_enabled: boolean;
oidc_connect_client_secret: string;
force_email_on_third_party_signup: boolean;
payment_visible_method_alipay_source: string;
payment_visible_method_wxpay_source: string;
payment_visible_method_alipay_enabled: boolean;
payment_visible_method_wxpay_enabled: boolean;
openai_advanced_scheduler_enabled: boolean;
};
@@ -4957,10 +4880,6 @@ const form = reactive<SettingsForm>({
payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: "rolling",
payment_visible_method_alipay_source: "",
payment_visible_method_wxpay_source: "",
payment_visible_method_alipay_enabled: false,
payment_visible_method_wxpay_enabled: false,
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{
@@ -5099,86 +5018,6 @@ const authSourceDefaultsMeta = computed(() => [
},
]);
const paymentVisibleMethodCards = computed(() => [
{
key: "alipay" as const,
title: t("payment.methods.alipay"),
enabledField: "payment_visible_method_alipay_enabled" as const,
sourceField: "payment_visible_method_alipay_source" as const,
},
{
key: "wxpay" as const,
title: t("payment.methods.wxpay"),
enabledField: "payment_visible_method_wxpay_enabled" as const,
sourceField: "payment_visible_method_wxpay_source" as const,
},
]);
function getPaymentVisibleMethodEnabled(method: "alipay" | "wxpay"): boolean {
return method === "alipay"
? form.payment_visible_method_alipay_enabled
: form.payment_visible_method_wxpay_enabled;
}
function setPaymentVisibleMethodEnabled(
method: "alipay" | "wxpay",
enabled: boolean,
) {
if (method === "alipay") {
form.payment_visible_method_alipay_enabled = enabled;
return;
}
form.payment_visible_method_wxpay_enabled = enabled;
}
function getPaymentVisibleMethodSource(method: "alipay" | "wxpay"): string {
return method === "alipay"
? form.payment_visible_method_alipay_source
: form.payment_visible_method_wxpay_source;
}
function getPaymentVisibleMethodSourceSelectOptions(
method: PaymentVisibleMethod,
) {
return getPaymentVisibleMethodSourceOptions(method).map((option) => ({
value: option.value,
label: localText(option.labelZh, option.labelEn),
}));
}
function setPaymentVisibleMethodSource(
method: "alipay" | "wxpay",
source: string | number | boolean | null,
) {
const normalized = normalizePaymentVisibleMethodSource(method, source);
if (method === "alipay") {
form.payment_visible_method_alipay_source = normalized;
return;
}
form.payment_visible_method_wxpay_source = normalized;
}
function validatePaymentVisibleMethodSelections(): boolean {
for (const visibleMethod of paymentVisibleMethodCards.value) {
if (!getPaymentVisibleMethodEnabled(visibleMethod.key)) {
continue;
}
if (getPaymentVisibleMethodSource(visibleMethod.key)) {
continue;
}
appStore.showError(
t("admin.settings.paymentVisibleMethods.sourceRequiredError", {
title: visibleMethod.title,
}),
);
return false;
}
return true;
}
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref<Proxy[]>([]);
@@ -5660,16 +5499,6 @@ async function loadSettings() {
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
settings.default_subscriptions,
);
form.payment_visible_method_alipay_source =
normalizePaymentVisibleMethodSource(
"alipay",
settings.payment_visible_method_alipay_source,
);
form.payment_visible_method_wxpay_source =
normalizePaymentVisibleMethodSource(
"wxpay",
settings.payment_visible_method_wxpay_source,
);
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist,
@@ -5873,7 +5702,6 @@ async function saveSettings() {
);
return;
}
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const isValidHttpUrl = (url: string): boolean => {
if (!url) return true;
@@ -6028,18 +5856,6 @@ async function saveSettings() {
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode:
form.payment_cancel_rate_limit_window_mode,
payment_visible_method_alipay_source: normalizePaymentVisibleMethodSource(
"alipay",
form.payment_visible_method_alipay_source,
),
payment_visible_method_wxpay_source: normalizePaymentVisibleMethodSource(
"wxpay",
form.payment_visible_method_wxpay_source,
),
payment_visible_method_alipay_enabled:
form.payment_visible_method_alipay_enabled,
payment_visible_method_wxpay_enabled:
form.payment_visible_method_wxpay_enabled,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
// Balance & quota notification
balance_low_notify_enabled: form.balance_low_notify_enabled,
@@ -6062,16 +5878,6 @@ async function saveSettings() {
}
}
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
form.payment_visible_method_alipay_source =
normalizePaymentVisibleMethodSource(
"alipay",
updated.payment_visible_method_alipay_source,
);
form.payment_visible_method_wxpay_source =
normalizePaymentVisibleMethodSource(
"wxpay",
updated.payment_visible_method_wxpay_source,
);
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist,
@@ -6588,8 +6394,98 @@ const cancelRateLimitModeOptions = computed(() => [
const paymentErrorMap = computed(() => ({
PENDING_ORDERS: t("payment.errors.PENDING_ORDERS"),
PAYMENT_PROVIDER_CONFLICT: t("payment.errors.PAYMENT_PROVIDER_CONFLICT"),
}));
type ProviderEnablementCandidate = Pick<
ProviderInstance,
"id" | "provider_key" | "supported_types" | "enabled" | "name"
>;
function getProviderVisibleMethods(
provider: ProviderEnablementCandidate,
): Array<"alipay" | "wxpay"> {
if (!provider.enabled) {
return [];
}
const supportedTypes = Array.isArray(provider.supported_types)
? provider.supported_types
: [];
const methods = new Set<"alipay" | "wxpay">();
const addMethod = (type: string) => {
const method = normalizeVisibleMethod(type);
if (method === "alipay" || method === "wxpay") {
methods.add(method);
}
};
if (provider.provider_key === "alipay") {
if (supportedTypes.length === 0) {
methods.add("alipay");
} else {
supportedTypes.forEach((type) => {
if (normalizeVisibleMethod(type) === "alipay") {
methods.add("alipay");
}
});
}
} else if (provider.provider_key === "wxpay") {
if (supportedTypes.length === 0) {
methods.add("wxpay");
} else {
supportedTypes.forEach((type) => {
if (normalizeVisibleMethod(type) === "wxpay") {
methods.add("wxpay");
}
});
}
} else if (provider.provider_key === "easypay") {
supportedTypes.forEach(addMethod);
}
return Array.from(methods);
}
function findProviderEnablementConflict(
candidate: ProviderEnablementCandidate,
): { method: "alipay" | "wxpay"; conflicting: ProviderInstance } | null {
const claimedMethods = getProviderVisibleMethods(candidate);
if (claimedMethods.length === 0) {
return null;
}
for (const other of providers.value) {
if (other.id === candidate.id || !other.enabled) {
continue;
}
const otherMethods = getProviderVisibleMethods(other);
const matchedMethod = claimedMethods.find((method) =>
otherMethods.includes(method),
);
if (matchedMethod) {
return {
method: matchedMethod,
conflicting: other,
};
}
}
return null;
}
function showProviderEnablementConflict(
conflict: { method: "alipay" | "wxpay"; conflicting: ProviderInstance },
) {
appStore.showError(
t("admin.settings.payment.enableConflict", {
method: t(`payment.methods.${conflict.method}`),
provider: conflict.conflicting.name,
}),
);
}
async function loadProviders() {
providersLoading.value = true;
try {
@@ -6619,6 +6515,21 @@ function openEditProvider(provider: ProviderInstance) {
async function handleSaveProvider(payload: Partial<ProviderInstance>) {
providerSaving.value = true;
try {
const candidate: ProviderEnablementCandidate = {
id: editingProvider.value?.id ?? 0,
provider_key:
payload.provider_key ?? editingProvider.value?.provider_key ?? "",
supported_types:
payload.supported_types ?? editingProvider.value?.supported_types ?? [],
enabled: payload.enabled ?? editingProvider.value?.enabled ?? false,
name: payload.name ?? editingProvider.value?.name ?? "",
};
const conflict = findProviderEnablementConflict(candidate);
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
if (editingProvider.value) {
await adminAPI.payment.updateProvider(editingProvider.value.id, payload);
} else {
@@ -6647,6 +6558,20 @@ async function handleToggleField(
else if (field === "refund_enabled") newValue = !provider.refund_enabled;
else newValue = !provider.allow_user_refund;
if (field === "enabled" && newValue) {
const conflict = findProviderEnablementConflict({
id: provider.id,
provider_key: provider.provider_key,
supported_types: provider.supported_types,
enabled: true,
name: provider.name,
});
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
}
const payload: Record<string, boolean> = { [field]: newValue };
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === "refund_enabled" && !newValue) {
@@ -6654,13 +6579,7 @@ async function handleToggleField(
}
try {
await adminAPI.payment.updateProvider(provider.id, payload);
if (field === "enabled") provider.enabled = newValue;
else if (field === "refund_enabled") {
provider.refund_enabled = newValue;
if (!newValue) provider.allow_user_refund = false;
} else {
provider.allow_user_refund = newValue;
}
await loadProviders();
} catch (err: unknown) {
appStore.showError(
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
@@ -6672,11 +6591,22 @@ async function handleToggleType(provider: ProviderInstance, type: string) {
const updated = provider.supported_types.includes(type)
? provider.supported_types.filter((t) => t !== type)
: [...provider.supported_types, type];
const conflict = findProviderEnablementConflict({
id: provider.id,
provider_key: provider.provider_key,
supported_types: updated,
enabled: provider.enabled,
name: provider.name,
});
if (conflict) {
showProviderEnablementConflict(conflict);
return;
}
try {
await adminAPI.payment.updateProvider(provider.id, {
supported_types: updated,
} as any);
provider.supported_types = updated;
await loadProviders();
} catch (err: unknown) {
appStore.showError(
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
@@ -6700,11 +6630,7 @@ async function handleReorderProviders(
} as Partial<ProviderInstance>),
),
);
// Update local state to match new order
for (const u of updates) {
const p = providers.value.find((p) => p.id === u.id);
if (p) p.sort_order = u.sort_order;
}
await loadProviders();
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
loadProviders();

View File

@@ -17,6 +17,9 @@ const {
getGroups,
listProxies,
getProviders,
updateProvider,
createProvider,
deleteProvider,
fetchPublicSettings,
adminSettingsFetch,
showError,
@@ -34,6 +37,9 @@ const {
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
updateProvider: vi.fn(),
createProvider: vi.fn(),
deleteProvider: vi.fn(),
fetchPublicSettings: vi.fn(),
adminSettingsFetch: vi.fn(),
showError: vi.fn(),
@@ -61,6 +67,9 @@ vi.mock("@/api", () => ({
},
payment: {
getProviders,
updateProvider,
createProvider,
deleteProvider,
},
},
}));
@@ -413,6 +422,9 @@ describe("admin SettingsView payment visible method controls", () => {
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
updateProvider.mockReset();
createProvider.mockReset();
deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
@@ -467,98 +479,93 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch.mockResolvedValue(undefined);
});
it("loads canonical source options and normalizes existing values", async () => {
it("does not render legacy visible payment method controls", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const paymentSourceSelects = wrapper
.findAll("select.select-stub")
.filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
expect(paymentSourceSelects).toHaveLength(2);
const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes("data-placeholder") === "alipay",
);
const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes("data-placeholder") === "wxpay",
);
expect(alipaySelect?.element.value).toBe("official_alipay");
expect(
alipaySelect?.findAll("option").map((option) => option.element.value),
).toEqual(["", "official_alipay", "easypay_alipay"]);
expect(wxpaySelect?.element.value).toBe("");
expect(
wxpaySelect?.findAll("option").map((option) => option.element.value),
).toEqual(["", "official_wxpay", "easypay_wxpay"]);
expect(wrapper.text()).not.toContain("可见方式");
expect(wrapper.text()).not.toContain("支付来源");
});
it("saves canonical source keys selected from the dropdowns", async () => {
it("does not submit legacy visible payment method settings", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const paymentSourceSelects = wrapper
.findAll("select.select-stub")
.filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes("data-placeholder") === "alipay",
);
const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes("data-placeholder") === "wxpay",
);
await alipaySelect?.setValue("easypay_alipay");
await wxpaySelect?.setValue("official_wxpay");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
payment_visible_method_alipay_source: "easypay_alipay",
payment_visible_method_wxpay_source: "official_wxpay",
payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true,
}),
);
const payload = updateSettings.mock.calls[0]?.[0];
expect(payload).not.toHaveProperty("payment_visible_method_alipay_source");
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_source");
expect(payload).not.toHaveProperty("payment_visible_method_alipay_enabled");
expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
});
it("blocks saving when a visible payment method is enabled without a source", async () => {
const wrapper = mountView();
it("updates provider enablement immediately and reloads providers", async () => {
const provider = {
id: 7,
provider_key: "alipay",
name: "Official Alipay",
config: {},
supported_types: ["alipay"],
enabled: false,
payment_mode: "",
refund_enabled: false,
allow_user_refund: false,
limits: "",
sort_order: 0,
};
getProviders.mockReset();
getProviders
.mockResolvedValueOnce({ data: [provider] })
.mockResolvedValueOnce({ data: [{ ...provider, enabled: true }] });
updateProvider.mockResolvedValue({ data: { ...provider, enabled: true } });
const PaymentProviderListStub = defineComponent({
emits: ["toggleField"],
setup(_, { emit }) {
return () =>
h(
"button",
{
class: "provider-toggle-stub",
onClick: () => emit("toggleField", provider, "enabled"),
},
"toggle provider",
);
},
});
const wrapper = mount(SettingsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
Select: SelectStub,
Toggle: ToggleStub,
Icon: true,
ConfirmDialog: true,
PaymentProviderList: PaymentProviderListStub,
PaymentProviderDialog: true,
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
BackupSettings: true,
},
},
});
await flushPromises();
await openPaymentTab(wrapper);
const paymentSourceSelects = wrapper
.findAll("select.select-stub")
.filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes("data-placeholder") === "alipay",
);
await alipaySelect?.setValue("");
await wrapper.find("form").trigger("submit.prevent");
await wrapper.get(".provider-toggle-stub").trigger("click");
await flushPromises();
expect(updateSettings).not.toHaveBeenCalled();
expect(showError).toHaveBeenCalled();
expect(String(showError.mock.calls.at(-1)?.[0] ?? "")).toContain(
"支付来源",
);
expect(updateProvider).toHaveBeenCalledWith(7, { enabled: true });
expect(getProviders).toHaveBeenCalledTimes(2);
});
it("renders advanced scheduler copy as local experimental gateway policy", async () => {
@@ -588,6 +595,9 @@ describe("admin SettingsView wechat connect controls", () => {
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
updateProvider.mockReset();
createProvider.mockReset();
deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();