sync: bring over remaining release/custom-0.1.115 changes

- Extract PublicSettingsInjectionPayload named struct with drift test
- Add channel_monitor_default_interval_seconds to SSR injection
- Add image_output_price to SupportedModelChip
- Simplify AppSidebar buildSelfNavItems (admins see available channels)
- Add gateway WARN logs for 503 no-available-accounts branches
- Wire ChannelMonitorRunner into provideCleanup for graceful shutdown
- Add migrations 130/131 (CC template userid fix + mimicry field cleanup)
- Clean up fork-only features (sora, claude max simulation, client affinity)
- Remove ~320 obsolete i18n keys
- Add codexUsage utility, WechatServiceButton, BulkEditAccountModal
- Tidy go.sum
This commit is contained in:
erio
2026-04-23 20:55:18 +08:00
parent d5dac84e12
commit 748a84d871
76 changed files with 1380 additions and 1699 deletions

View File

@@ -122,8 +122,11 @@
>
{{ siteName }}
</h1>
<p class="mb-8 text-lg text-gray-600 dark:text-dark-300 md:text-xl">
{{ siteSubtitle }}
<p class="mb-3 text-xl font-semibold text-primary-600 dark:text-primary-400 md:text-2xl">
{{ t('home.heroSubtitle') }}
</p>
<p class="mb-8 text-base text-gray-600 dark:text-dark-300 md:text-lg">
{{ t('home.heroDescription') }}
</p>
<!-- CTA Button -->
@@ -177,7 +180,7 @@
</div>
<!-- Feature Tags - Centered -->
<div class="mb-12 flex flex-wrap items-center justify-center gap-4 md:gap-6">
<div class="mb-16 flex flex-wrap items-center justify-center gap-4 md:gap-6">
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
@@ -204,6 +207,63 @@
</div>
</div>
<!-- Pain Points Section -->
<div class="mb-16">
<h2 class="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
{{ t('home.painPoints.title') }}
</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Pain Point 1: Expensive -->
<div class="rounded-xl border border-red-200/50 bg-red-50/50 p-5 dark:border-red-900/30 dark:bg-red-950/20">
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.expensive.title') }}</h3>
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.expensive.desc') }}</p>
</div>
<!-- Pain Point 2: Complex -->
<div class="rounded-xl border border-orange-200/50 bg-orange-50/50 p-5 dark:border-orange-900/30 dark:bg-orange-950/20">
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
<svg class="h-5 w-5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.complex.title') }}</h3>
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.complex.desc') }}</p>
</div>
<!-- Pain Point 3: Unstable -->
<div class="rounded-xl border border-yellow-200/50 bg-yellow-50/50 p-5 dark:border-yellow-900/30 dark:bg-yellow-950/20">
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100 dark:bg-yellow-900/30">
<svg class="h-5 w-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.unstable.title') }}</h3>
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.unstable.desc') }}</p>
</div>
<!-- Pain Point 4: No Control -->
<div class="rounded-xl border border-gray-200/50 bg-gray-50/50 p-5 dark:border-dark-700/50 dark:bg-dark-800/50">
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-dark-700">
<svg class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<h3 class="mb-1.5 font-semibold text-gray-900 dark:text-white">{{ t('home.painPoints.items.noControl.title') }}</h3>
<p class="text-sm text-gray-600 dark:text-dark-400">{{ t('home.painPoints.items.noControl.desc') }}</p>
</div>
</div>
</div>
<!-- Solutions Section Title -->
<div class="mb-8 text-center">
<h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
{{ t('home.solutions.title') }}
</h2>
<p class="text-gray-600 dark:text-dark-400">{{ t('home.solutions.subtitle') }}</p>
</div>
<!-- Features Grid -->
<div class="mb-12 grid gap-6 md:grid-cols-3">
<!-- Feature 1: Unified Gateway -->
@@ -369,6 +429,77 @@
>
</div>
</div>
<!-- Comparison Table -->
<div class="mb-16">
<h2 class="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-white md:text-3xl">
{{ t('home.comparison.title') }}
</h2>
<div class="overflow-x-auto">
<table class="w-full rounded-xl border border-gray-200/50 bg-white/60 backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/60">
<thead>
<tr class="border-b border-gray-200/50 dark:border-dark-700/50">
<th class="px-6 py-4 text-left text-sm font-semibold text-gray-900 dark:text-white">{{ t('home.comparison.headers.feature') }}</th>
<th class="px-6 py-4 text-center text-sm font-semibold text-gray-500 dark:text-dark-400">{{ t('home.comparison.headers.official') }}</th>
<th class="px-6 py-4 text-center text-sm font-semibold text-primary-600 dark:text-primary-400">{{ t('home.comparison.headers.us') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50 dark:divide-dark-700/50">
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.pricing.feature') }}</td>
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.pricing.official') }}</td>
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.pricing.us') }}</td>
</tr>
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.models.feature') }}</td>
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.models.official') }}</td>
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.models.us') }}</td>
</tr>
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.management.feature') }}</td>
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.management.official') }}</td>
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.management.us') }}</td>
</tr>
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.stability.feature') }}</td>
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.stability.official') }}</td>
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.stability.us') }}</td>
</tr>
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">{{ t('home.comparison.items.control.feature') }}</td>
<td class="px-6 py-4 text-center text-sm text-gray-500 dark:text-dark-400">{{ t('home.comparison.items.control.official') }}</td>
<td class="px-6 py-4 text-center text-sm font-medium text-primary-600 dark:text-primary-400">{{ t('home.comparison.items.control.us') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CTA Section -->
<div class="mb-8 rounded-2xl bg-gradient-to-r from-primary-500 to-primary-600 p-8 text-center shadow-xl shadow-primary-500/20 md:p-12">
<h2 class="mb-3 text-2xl font-bold text-white md:text-3xl">
{{ t('home.cta.title') }}
</h2>
<p class="mb-6 text-primary-100">
{{ t('home.cta.description') }}
</p>
<router-link
v-if="!isAuthenticated"
to="/register"
class="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition-all hover:bg-gray-50 hover:shadow-xl"
>
{{ t('home.cta.button') }}
<Icon name="arrowRight" size="md" :stroke-width="2" />
</router-link>
<router-link
v-else
:to="dashboardPath"
class="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition-all hover:bg-gray-50 hover:shadow-xl"
>
{{ t('home.goToDashboard') }}
<Icon name="arrowRight" size="md" :stroke-width="2" />
</router-link>
</div>
</div>
</main>
@@ -380,27 +511,20 @@
<p class="text-sm text-gray-500 dark:text-dark-400">
&copy; {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
</p>
<div class="flex items-center gap-4">
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
{{ t('home.docs') }}
</a>
<a
:href="githubUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
GitHub
</a>
</div>
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
{{ t('home.docs') }}
</a>
</div>
</footer>
<!-- 微信客服悬浮按钮 -->
<WechatServiceButton />
</div>
</template>
@@ -410,6 +534,7 @@ import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
import WechatServiceButton from '@/components/common/WechatServiceButton.vue'
const { t } = useI18n()
@@ -419,7 +544,6 @@ const appStore = useAppStore()
// Site settings - directly from appStore (already initialized from injected config)
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
@@ -432,9 +556,6 @@ const isHomeContentUrl = computed(() => {
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'))
// GitHub URL
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isAdmin = computed(() => authStore.isAdmin)

View File

@@ -3751,91 +3751,90 @@
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/monitor"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.channelMonitor.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.channelMonitor.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.features.channelMonitor.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.features.channelMonitor.description") }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/monitor"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t("admin.settings.features.channelMonitor.configureLink") }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
</div>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.features.channelMonitor.enabled") }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.features.channelMonitor.enabledHint") }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.availableChannels.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/pricing"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.availableChannels.configureLink') }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.availableChannels.enabled') }}
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t("admin.settings.features.channelMonitor.defaultInterval") }}
<span class="text-red-500">*</span>
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.availableChannels.enabledHint') }}
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t("admin.settings.features.channelMonitor.defaultIntervalHint") }}
</p>
</div>
<Toggle v-model="form.available_channels_enabled" />
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.features.availableChannels.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.features.availableChannels.description") }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/channels/pricing"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t("admin.settings.features.availableChannels.configureLink") }}
<span aria-hidden="true">→</span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.features.availableChannels.enabled") }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.features.availableChannels.enabledHint") }}
</p>
</div>
<Toggle v-model="form.available_channels_enabled" />
</div>
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
@@ -4254,8 +4253,6 @@
}}</label>
<ImageUpload
v-model="form.payment_help_image_url"
:upload-label="t('admin.settings.site.uploadImage')"
:remove-label="t('admin.settings.site.remove')"
:placeholder="
t('admin.settings.payment.helpImagePlaceholder')
"

View File

@@ -155,8 +155,6 @@ vi.mock("vue-i18n", async () => {
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
"admin.settings.site.uploadImage": "上传图片",
"admin.settings.site.remove": "移除",
};
return {
...actual,
@@ -242,37 +240,6 @@ const SelectStub = defineComponent({
},
});
const ImageUploadStub = defineComponent({
props: {
modelValue: {
type: String,
default: "",
},
uploadLabel: {
type: String,
default: "",
},
removeLabel: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "",
},
},
setup(props) {
return () =>
h("div", {
class: "image-upload-stub",
"data-model-value": props.modelValue,
"data-upload-label": props.uploadLabel,
"data-remove-label": props.removeLabel,
"data-placeholder": props.placeholder,
});
},
});
const baseSettingsResponse = {
registration_enabled: true,
email_verify_enabled: false,
@@ -408,7 +375,7 @@ function mountView() {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: ImageUploadStub,
ImageUpload: true,
BackupSettings: true,
},
},
@@ -615,7 +582,7 @@ describe("admin SettingsView payment visible method controls", () => {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: ImageUploadStub,
ImageUpload: true,
BackupSettings: true,
},
},
@@ -641,24 +608,6 @@ describe("admin SettingsView payment visible method controls", () => {
);
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
it("passes translated upload and remove labels to the payment help image uploader", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const imageUploads = wrapper.findAll(".image-upload-stub");
expect(imageUploads.length).toBeGreaterThan(0);
const paymentHelpImageUpload = imageUploads.find(
(node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder",
);
expect(paymentHelpImageUpload).toBeDefined();
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
});
});
describe("admin SettingsView wechat connect controls", () => {

View File

@@ -122,6 +122,7 @@ const platformRows = computed((): SummaryRow[] => {
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
@@ -161,7 +162,6 @@ const groupRows = computed((): SummaryRow[] => {
total_accounts: totalAccounts,
available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count),
error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency,
@@ -329,6 +329,7 @@ function formatDuration(seconds: number): string {
}
watch(
() => realtimeEnabled.value,
async (enabled) => {

View File

@@ -311,7 +311,6 @@ interface CreateOrderOptions {
wechatResumeToken?: string
paymentType?: string
isResume?: boolean
mobileQrFallbackAttempted?: boolean
}
interface WeixinJSBridgeLike {
@@ -667,15 +666,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
submitting.value = true
errorMessage.value = ''
errorHintMessage.value = ''
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
try {
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
amount: orderAmount,
paymentType: requestType,
orderType,
planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: isMobileDevice(),
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
})
if (options.openid) {
@@ -749,20 +747,8 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
appStore.showInfo(t('payment.qr.cancelled'))
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
const fallbackApplied = await attemptMobileQrFallback(
{ reason: 'WECHAT_JSAPI_FAILED', message: errMsg },
{
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
},
)
if (!fallbackApplied) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
}
} else {
const resultState = { ...decision.paymentState }
resetPayment()
@@ -770,16 +756,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
} catch (err: unknown) {
resetPayment()
const fallbackApplied = await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: visibleMethod,
attempted: options.mobileQrFallbackAttempted === true,
})
if (!fallbackApplied) {
throw err
}
throw err
}
return
}
@@ -799,14 +776,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited')
errorHintMessage.value = ''
} else if (await attemptMobileQrFallback(err, {
orderAmount,
orderType,
planId,
paymentType: requestType,
attempted: options.mobileQrFallbackAttempted === true,
})) {
return
} else {
const handled = applyScenarioError(
err,
@@ -826,101 +795,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
interface MobileQrFallbackContext {
orderAmount: number
orderType: OrderType
planId?: number
paymentType: string
attempted: boolean
}
function shouldFallbackToDesktopQr(err: unknown, paymentMethod: string, attempted: boolean): boolean {
if (attempted || !isMobileDevice()) {
return false
}
const normalizedMethod = normalizeVisibleMethod(paymentMethod) || paymentMethod
const reason = typeof err === 'object' && err && 'reason' in err && typeof err.reason === 'string'
? err.reason
: ''
const message = err instanceof Error
? err.message
: (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string'
? err.message
: '')
const normalizedMessage = message.toLowerCase()
if (normalizedMethod === 'wxpay') {
return reason === 'WECHAT_H5_NOT_AUTHORIZED'
|| reason === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED'
|| reason === 'WECHAT_JSAPI_FAILED'
|| reason === 'PAYMENT_GATEWAY_ERROR'
|| reason === 'UNHANDLED_PAYMENT_SCENARIO'
|| normalizedMessage.includes('weixinjsbridge is unavailable')
|| normalizedMessage.includes('wechat_jsapi_unavailable')
}
if (normalizedMethod === 'alipay') {
return reason === 'PAYMENT_GATEWAY_ERROR' || reason === 'UNHANDLED_PAYMENT_SCENARIO'
}
return false
}
async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackContext): Promise<boolean> {
if (!shouldFallbackToDesktopQr(err, context.paymentType, context.attempted)) {
return false
}
try {
const visibleMethod = normalizeVisibleMethod(context.paymentType) || context.paymentType
const payload = buildCreateOrderPayload({
amount: context.orderAmount,
paymentType: visibleMethod,
orderType: context.orderType,
planId: context.planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isMobile: false,
isWechatBrowser: false,
})
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
resume_token: result.resume_token || undefined,
},
}).href
: ''
const decision = decidePaymentLaunch(result, {
visibleMethod,
orderType: context.orderType,
isMobile: false,
isWechatBrowser: false,
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
})
if (decision.kind !== 'qr_waiting' || !decision.paymentState.qrCode) {
return false
}
errorMessage.value = ''
errorHintMessage.value = ''
paymentState.value = decision.paymentState
paymentPhase.value = 'paying'
persistRecoverySnapshot(decision.recovery)
appStore.showWarning(t('payment.errors.mobilePaymentFallbackToQr'))
return true
} catch {
return false
}
}
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
const descriptor = describePaymentScenarioError(err, {
paymentMethod,

View File

@@ -16,7 +16,6 @@ const refreshUser = vi.hoisted(() => vi.fn())
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const showError = vi.hoisted(() => vi.fn())
const showInfo = vi.hoisted(() => vi.fn())
const showWarning = vi.hoisted(() => vi.fn())
const getCheckoutInfo = vi.hoisted(() => vi.fn())
const bridgeInvoke = vi.hoisted(() => vi.fn())
@@ -70,7 +69,6 @@ vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
showInfo,
showWarning,
}),
}))
@@ -195,7 +193,6 @@ describe('PaymentView WeChat JSAPI flow', () => {
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
showError.mockReset()
showInfo.mockReset()
showWarning.mockReset()
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
bridgeInvoke.mockReset()
window.localStorage.clear()
@@ -367,24 +364,13 @@ describe('PaymentView WeChat JSAPI flow', () => {
})
})
it('falls back to QR flow when mobile WeChat payment is unavailable', async () => {
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder
.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
.mockResolvedValueOnce({
order_id: 778,
amount: 88,
pay_amount: 88,
fee_rate: 0,
expires_at: '2099-01-01T00:10:00.000Z',
payment_type: 'wxpay',
qr_code: 'weixin://wxpay/bizpayurl?pr=fallback-native',
out_trade_no: 'sub2_qr_778',
})
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
shallowMount(PaymentView, {
global: {
@@ -397,18 +383,8 @@ describe('PaymentView WeChat JSAPI flow', () => {
await flushPromises()
await flushPromises()
expect(createOrder).toHaveBeenNthCalledWith(1, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: true,
wechat_resume_token: 'resume-token-h5',
}))
expect(createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({
payment_type: 'wxpay',
is_mobile: false,
payment_source: 'hosted_redirect',
}))
expect(showWarning).toHaveBeenCalledWith('payment.errors.mobilePaymentFallbackToQr')
expect(showError).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toContain('weixin://wxpay/bizpayurl?pr=fallback-native')
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
})
})