Commit 67518a59 authored by erio's avatar erio
Browse files

revert: remove fork-only changes from release sync

Revert payment/wechat, sora/claude-max cleanup, fork-only migrations,
and cosmetic changes that were brought in by the release sync commit.
Keep only channel-monitor related improvements:
- PublicSettingsInjectionPayload named struct with drift test
- ChannelMonitorRunner graceful shutdown in wire
- image_output_price in SupportedModelChip
- Simplified buildSelfNavItems in AppSidebar
- Gateway WARN logs for 503 branches
parent a3ea8eca
import { describe, expect, it } from 'vitest'
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
describe('resolveCodexUsageWindow', () => {
it('快照为空时返回空窗口', () => {
const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
expect(result).toEqual({ usedPercent: null, resetAt: null })
})
it('优先使用后端提供的绝对重置时间', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 55,
codex_5h_reset_at: '2026-02-20T10:00:00Z',
codex_5h_reset_after_seconds: 1
},
'5h',
new Date('2026-02-20T08:00:00Z')
)
expect(result.usedPercent).toBe(55)
expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
})
it('窗口已过期时自动归零', () => {
const result = resolveCodexUsageWindow(
{
codex_7d_used_percent: 100,
codex_7d_reset_at: '2026-02-20T07:00:00Z'
},
'7d',
new Date('2026-02-20T08:00:00Z')
)
expect(result.usedPercent).toBe(0)
expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
})
it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 20,
codex_5h_reset_after_seconds: 3600,
codex_usage_updated_at: '2026-02-20T06:30:00Z'
},
'5h',
new Date('2026-02-20T07:00:00Z')
)
expect(result.usedPercent).toBe(20)
expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
})
it('支持 legacy primary/secondary 字段映射', () => {
const snapshot = {
codex_primary_window_minutes: 10080,
codex_primary_used_percent: 70,
codex_primary_reset_after_seconds: 86400,
codex_secondary_window_minutes: 300,
codex_secondary_used_percent: 15,
codex_secondary_reset_after_seconds: 1200,
codex_usage_updated_at: '2026-02-20T07:00:00Z'
}
const result5h = resolveCodexUsageWindow(snapshot, '5h', new Date('2026-02-20T07:05:00Z'))
const result7d = resolveCodexUsageWindow(snapshot, '7d', new Date('2026-02-20T07:05:00Z'))
expect(result5h.usedPercent).toBe(15)
expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
expect(result7d.usedPercent).toBe(70)
expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
})
it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
const result = resolveCodexUsageWindow(
{
codex_primary_window_minutes: '300',
codex_primary_used_percent: '21',
codex_primary_reset_after_seconds: '1800',
codex_secondary_window_minutes: '10080',
codex_secondary_used_percent: '99',
codex_secondary_reset_after_seconds: '99999',
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T08:10:00Z')
)
expect(result.usedPercent).toBe(21)
expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
})
it('legacy 5h 在无窗口信息时回退 secondary', () => {
const result = resolveCodexUsageWindow(
{
codex_secondary_used_percent: 19,
codex_secondary_reset_after_seconds: 120,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T08:00:01Z')
)
expect(result.usedPercent).toBe(19)
expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
})
it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
const result = resolveCodexUsageWindow(
{
codex_primary_window_minutes: 300,
codex_primary_used_percent: 5,
codex_primary_reset_after_seconds: 600,
codex_secondary_window_minutes: 10080,
codex_secondary_used_percent: 66,
codex_secondary_reset_after_seconds: 7200,
codex_usage_updated_at: '2026-02-20T07:00:00Z'
},
'7d',
new Date('2026-02-20T07:30:00Z')
)
expect(result.usedPercent).toBe(66)
expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
})
it('绝对时间非法时回退到 updated_at + seconds', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 33,
codex_5h_reset_at: 'not-a-date',
codex_5h_reset_after_seconds: 900,
codex_usage_updated_at: '2026-02-20T07:30:00Z'
},
'5h',
new Date('2026-02-20T07:40:00Z')
)
expect(result.usedPercent).toBe(33)
expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
})
it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 10,
codex_5h_reset_after_seconds: 123,
codex_usage_updated_at: 'invalid-time'
},
'5h',
new Date('2026-02-20T08:00:00Z')
)
expect(result.usedPercent).toBe(10)
expect(result.resetAt).toBeNull()
})
it('reset_after_seconds 为负数时按 0 秒处理', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 80,
codex_5h_reset_after_seconds: -30,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T07:59:00Z')
)
expect(result.usedPercent).toBe(80)
expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
})
it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
const result = resolveCodexUsageWindow(
{
codex_7d_reset_after_seconds: 60,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'7d',
new Date('2026-02-20T08:00:01Z')
)
expect(result.usedPercent).toBeNull()
expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
})
})
import { describe, expect, it } from 'vitest'
import { enqueueUsageRequest } from '../usageLoadQueue'
import type { Account } from '@/types'
/** Helper to create a minimal Account with proxy info */
function makeAccount(
platform: string,
type: string = 'oauth',
proxy?: { host: string; port: number; username?: string | null } | null
): Account {
return {
id: Math.floor(Math.random() * 10000),
platform,
type,
name: 'test',
status: 'active',
proxy_id: proxy ? 1 : null,
proxy: proxy
? { id: 1, name: 'p', protocol: 'http', host: proxy.host, port: proxy.port, username: proxy.username ?? null, status: 'active', created_at: '', updated_at: '' }
: undefined,
credentials: {},
created_at: '',
updated_at: ''
} as unknown as Account
}
describe('usageLoadQueue', () => {
// ─── Anthropic 账号:按代理出口排队 ───
it('Anthropic 同代理出口串行执行,间隔 >= 1s', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const p1 = enqueueUsageRequest(acc, makeFn())
const p2 = enqueueUsageRequest(acc, makeFn())
const p3 = enqueueUsageRequest(acc, makeFn())
await Promise.all([p1, p2, p3])
expect(timestamps).toHaveLength(3)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
expect(timestamps[1] - timestamps[0]).toBeLessThan(2100)
expect(timestamps[2] - timestamps[1]).toBeGreaterThanOrEqual(950)
expect(timestamps[2] - timestamps[1]).toBeLessThan(2100)
})
it('Anthropic 不同代理出口并行执行', async () => {
const timestamps: Record<string, number> = {}
const makeTracked = (key: string) => async () => {
timestamps[key] = Date.now()
return key
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const acc2 = makeAccount('anthropic', 'oauth', { host: '5.6.7.8', port: 3128, username: 'u2' })
const p1 = enqueueUsageRequest(acc1, makeTracked('proxy1'))
const p2 = enqueueUsageRequest(acc2, makeTracked('proxy2'))
await Promise.all([p1, p2])
const spread = Math.abs(timestamps['proxy1'] - timestamps['proxy2'])
expect(spread).toBeLessThan(50)
})
it('Anthropic 相同代理连接信息的不同账号归为同一队列', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '10.0.0.1', port: 3128, username: 'admin' })
const acc2 = makeAccount('anthropic', 'setup-token', { host: '10.0.0.1', port: 3128, username: 'admin' })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
})
it('Anthropic 直连(无代理)的账号归为同一队列', async () => {
const order: number[] = []
const makeFn = (n: number) => async () => {
order.push(n)
return n
}
const acc1 = makeAccount('anthropic', 'oauth')
const acc2 = makeAccount('anthropic', 'setup-token')
const p1 = enqueueUsageRequest(acc1, makeFn(1))
const p2 = enqueueUsageRequest(acc2, makeFn(2))
await Promise.all([p1, p2])
expect(order).toEqual([1, 2])
})
it('Anthropic 请求失败时 reject,后续任务继续执行', async () => {
const results: string[] = []
const acc = makeAccount('anthropic', 'oauth', { host: '99.99.99.99', port: 1234 })
const p1 = enqueueUsageRequest(acc, async () => {
throw new Error('fail')
})
const p2 = enqueueUsageRequest(acc, async () => {
results.push('second')
return 'ok'
})
await expect(p1).rejects.toThrow('fail')
await p2
expect(results).toEqual(['second'])
})
// ─── 非 Anthropic 平台:直接执行,不排队 ───
it('非 Anthropic 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
// 同一代理的 Gemini 账号 — 应当并行,不排队
const acc1 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
// 并行执行,几乎同时完成
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
it('Antigravity 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('antigravity', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('antigravity', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
it('OpenAI 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── Anthropic apikey 类型不排队 ───
it('Anthropic apikey 类型直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── 返回值透传 ───
it('返回值正确透传', async () => {
const acc = makeAccount('anthropic', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { usage: 42 }
})
expect(result).toEqual({ usage: 42 })
})
it('非 Anthropic 返回值正确透传', async () => {
const acc = makeAccount('gemini', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { quota: 100 }
})
expect(result).toEqual({ quota: 100 })
})
})
import type { CodexUsageSnapshot } from '@/types'
export interface ResolvedCodexUsageWindow {
usedPercent: number | null
resetAt: string | null
}
type WindowKind = '5h' | '7d'
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value)
if (Number.isFinite(n)) return n
}
return null
}
function asString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
function asISOTime(value: unknown): string | null {
const raw = asString(value)
if (!raw) return null
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}
function resolveLegacy5h(snapshot: Record<string, unknown>): {
used: number | null
resetAfterSeconds: number | null
} {
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
if (primaryWindow != null && primaryWindow <= 360) {
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
if (secondaryWindow != null && secondaryWindow <= 360) {
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
function resolveLegacy7d(snapshot: Record<string, unknown>): {
used: number | null
resetAfterSeconds: number | null
} {
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
if (primaryWindow != null && primaryWindow >= 10000) {
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
if (secondaryWindow != null && secondaryWindow >= 10000) {
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
function resolveFromSeconds(
snapshot: Record<string, unknown>,
resetAfterSeconds: number | null
): string | null {
if (resetAfterSeconds == null) return null
const baseRaw = asString(snapshot.codex_usage_updated_at)
const base = baseRaw ? new Date(baseRaw) : new Date()
if (Number.isNaN(base.getTime())) return null
const sec = Math.max(0, resetAfterSeconds)
const resetAt = new Date(base.getTime() + sec * 1000)
return resetAt.toISOString()
}
function applyExpiredRule(
window: ResolvedCodexUsageWindow,
now: Date
): ResolvedCodexUsageWindow {
if (window.usedPercent == null || !window.resetAt) return window
const resetDate = new Date(window.resetAt)
if (Number.isNaN(resetDate.getTime())) return window
if (resetDate.getTime() <= now.getTime()) {
return { usedPercent: 0, resetAt: resetDate.toISOString() }
}
return window
}
export function resolveCodexUsageWindow(
snapshot: (CodexUsageSnapshot & Record<string, unknown>) | null | undefined,
window: WindowKind,
now: Date = new Date()
): ResolvedCodexUsageWindow {
if (!snapshot) {
return { usedPercent: null, resetAt: null }
}
const typedSnapshot = snapshot as Record<string, unknown>
let usedPercent: number | null
let resetAfterSeconds: number | null
let resetAt: string | null
if (window === '5h') {
usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
const legacy = resolveLegacy5h(typedSnapshot)
if (usedPercent == null) usedPercent = legacy.used
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
}
} else {
usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
const legacy = resolveLegacy7d(typedSnapshot)
if (usedPercent == null) usedPercent = legacy.used
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
}
}
if (!resetAt) {
resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
}
return applyExpiredRule({ usedPercent, resetAt }, now)
}
/**
* Shared URL builder for iframe-embedded pages.
* Used by CustomPageView to build consistent URLs
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
* with user_id, token, theme, lang, ui_mode, src_host, and src parameters.
*/
......
......@@ -122,11 +122,8 @@
>
{{ siteName }}
</h1>
<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 class="mb-8 text-lg text-gray-600 dark:text-dark-300 md:text-xl">
{{ siteSubtitle }}
</p>
<!-- CTA Button -->
......@@ -180,7 +177,7 @@
</div>
<!-- Feature Tags - Centered -->
<div class="mb-16 flex flex-wrap items-center justify-center gap-4 md:gap-6">
<div class="mb-12 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"
>
......@@ -207,63 +204,6 @@
</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 -->
......@@ -429,77 +369,6 @@
>
</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>
......@@ -511,6 +380,7 @@
<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"
......@@ -520,11 +390,17 @@
>
{{ 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>
</div>
</footer>
<!-- 微信客服悬浮按钮 -->
<WechatServiceButton />
</div>
</template>
......@@ -534,7 +410,6 @@ 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()
......@@ -544,6 +419,7 @@ 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 || '')
......@@ -556,6 +432,9 @@ 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,20 +3751,21 @@
<!-- 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") }}
{{ 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") }}
{{ 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") }}
{{ t('admin.settings.features.channelMonitor.configureLink') }}
<span aria-hidden="true"></span>
</router-link>
</p>
......@@ -3773,10 +3774,10 @@
<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") }}
{{ 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") }}
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
......@@ -3784,7 +3785,7 @@
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t("admin.settings.features.channelMonitor.defaultInterval") }}
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
......@@ -3795,7 +3796,7 @@
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t("admin.settings.features.channelMonitor.defaultIntervalHint") }}
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
</div>
</div>
......@@ -3804,17 +3805,17 @@
<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") }}
{{ 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") }}
{{ 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") }}
{{ t('admin.settings.features.availableChannels.configureLink') }}
<span aria-hidden="true"></span>
</router-link>
</p>
......@@ -3823,18 +3824,18 @@
<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") }}
{{ 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") }}
{{ t('admin.settings.features.availableChannels.enabledHint') }}
</p>
</div>
<Toggle v-model="form.available_channels_enabled" />
</div>
</div>
</div>
</div>
<!-- /Tab: Features -->
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
......@@ -4253,6 +4254,8 @@
}}</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,6 +155,8 @@ 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,
......@@ -240,6 +242,37 @@ 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,
......@@ -375,7 +408,7 @@ function mountView() {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
......@@ -582,7 +615,7 @@ describe("admin SettingsView payment visible method controls", () => {
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
ImageUpload: ImageUploadStub,
BackupSettings: true,
},
},
......@@ -608,6 +641,24 @@ 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,7 +122,6 @@ 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,
......@@ -162,6 +161,7 @@ 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,7 +329,6 @@ function formatDuration(seconds: number): string {
}
watch(
() => realtimeEnabled.value,
async (enabled) => {
......
......@@ -311,6 +311,7 @@ interface CreateOrderOptions {
wechatResumeToken?: string
paymentType?: string
isResume?: boolean
mobileQrFallbackAttempted?: boolean
}
interface WeixinJSBridgeLike {
......@@ -666,14 +667,15 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
submitting.value = true
errorMessage.value = ''
errorHintMessage.value = ''
try {
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
try {
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) {
......@@ -747,8 +749,20 @@ 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()
......@@ -756,8 +770,17 @@ 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
}
}
return
}
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
......@@ -776,6 +799,14 @@ 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,
......@@ -795,6 +826,101 @@ 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,6 +16,7 @@ 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())
......@@ -69,6 +70,7 @@ vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
showInfo,
showWarning,
}),
}))
......@@ -193,6 +195,7 @@ describe('PaymentView WeChat JSAPI flow', () => {
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
showError.mockReset()
showInfo.mockReset()
showWarning.mockReset()
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
bridgeInvoke.mockReset()
window.localStorage.clear()
......@@ -364,13 +367,24 @@ describe('PaymentView WeChat JSAPI flow', () => {
})
})
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
it('falls back to QR flow when mobile WeChat payment is unavailable', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
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',
})
shallowMount(PaymentView, {
global: {
......@@ -383,8 +397,18 @@ describe('PaymentView WeChat JSAPI flow', () => {
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
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')
})
})
......@@ -13,7 +13,6 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment