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:
@@ -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">
|
||||
© {{ 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)
|
||||
|
||||
@@ -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')
|
||||
"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user