Merge branch 'main' into fix/image-tier-validation

This commit is contained in:
name committed 2026-05-19 11:06:17 +08:00
commit ddab7de916
39 files changed
+1525 -124

No files matched your search

@@ -34,7 +34,7 @@
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<div v-if="supportsPaymentMode" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5">
<button
@@ -278,11 +278,37 @@ import {
WEBHOOK_PATHS,
PAYMENT_MODE_QRCODE,
PAYMENT_MODE_POPUP,
PAYMENT_MODE_REDIRECT,
STRIPE_SDK_API_VERSION,
getAvailableTypes,
extractBaseUrl,
} from './providerConfig'
/** Default payment_mode per provider key — "" means "no preference, use
* provider's built-in default behavior". */
function defaultPaymentMode(providerKey: string): string {
if (providerKey === 'easypay') return PAYMENT_MODE_QRCODE
return ''
}
/** Provider keys whose admin UI exposes a payment_mode selector.
* Other providers always send payment_mode = ''. */
function providerSupportsPaymentMode(providerKey: string): boolean {
return providerKey === 'easypay' || providerKey === 'alipay'
}
/** Allowed payment_mode values per provider. Used to coerce DB values
* from a different provider (or stale data) back to the default. */
function isValidPaymentMode(providerKey: string, mode: string): boolean {
if (providerKey === 'easypay') {
return mode === PAYMENT_MODE_QRCODE || mode === PAYMENT_MODE_POPUP
}
if (providerKey === 'alipay') {
return mode === '' || mode === PAYMENT_MODE_REDIRECT
}
return mode === ''
}
const props = defineProps<{
show: boolean
saving: boolean
@@ -359,7 +385,17 @@ const providerWebhookHint = computed(() =>
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
const supportsPaymentMode = computed(() => providerSupportsPaymentMode(form.provider_key))
const paymentModeOptions = computed(() => {
if (form.provider_key === 'alipay') {
// For Alipay official: "" = default (precreate → page.pay fallback);
// "redirect" = always open the Alipay checkout page in a new tab.
return [
{ value: '', label: t('admin.settings.payment.modeQRCode') },
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
]
}
return [
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
@@ -476,6 +512,7 @@ function toggleType(type: string) {
function onKeyChange() {
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
form.payment_mode = defaultPaymentMode(form.provider_key)
clearConfig()
applyDefaults()
}
@@ -591,7 +628,7 @@ function handleSave() {
name: form.name,
supported_types: form.supported_types,
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
payment_mode: supportsPaymentMode.value ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
config: filteredConfig,
@@ -611,7 +648,7 @@ function reset(defaultKey: string) {
form.provider_key = defaultKey
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.payment_mode = defaultPaymentMode(defaultKey)
form.refund_enabled = false
form.allow_user_refund = false
clearConfig()
@@ -623,7 +660,12 @@ function loadProvider(provider: ProviderInstance) {
form.provider_key = provider.provider_key
form.supported_types = provider.supported_types
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
// Coerce to a valid value for this provider. Guards against stale data
// (e.g. "popup" written by an older client) showing up as an unselected
// button in the dialog.
form.payment_mode = isValidPaymentMode(provider.provider_key, provider.payment_mode || '')
? (provider.payment_mode || '')
: defaultPaymentMode(provider.provider_key)
form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig()
@@ -69,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP, PAYMENT_MODE_REDIRECT } from './providerConfig'
const PROVIDER_KEY_LABELS: Record<string, string> = {
easypay: 'admin.settings.payment.providerEasypay',
@@ -99,6 +99,7 @@ const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_ke
const modeLabel = computed(() => {
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
if (props.provider.payment_mode === PAYMENT_MODE_REDIRECT) return t('admin.settings.payment.modeRedirect')
return ''
})
@@ -47,6 +47,11 @@ export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct',
/** Payment mode constants */
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_POPUP = 'popup'
/** Alipay-only: skip FACE_TO_FACE_PAYMENT precreate and open the Alipay
* checkout page in a new tab instead. Backend `alipay.go` matches on this
* literal (case-insensitive); other values fall back to the default
* precreate→pagepay flow. */
export const PAYMENT_MODE_REDIRECT = 'redirect'
export const PAYMENT_CURRENCY_OPTIONS: TypeOption[] = [
{ value: 'CNY', label: 'CNY' },
+1 -1
View File
@@ -5053,7 +5053,7 @@ export default {
switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).',
latencyHistogram: 'Request duration distribution (ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.',
errorDistribution: 'Error distribution by status code (SLA scope, excluding business limits).',
goroutines:
'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.',
cpu: 'CPU usage percentage, showing system processor load.',
+1 -1
View File
@@ -5216,7 +5216,7 @@ export default {
switchRateTrend: '近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。',
latencyHistogram: '成功请求的请求时长分布(毫秒)。',
errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。',
errorDistribution: '按状态码统计的错误分布。',
errorDistribution: '按状态码统计的错误分布(SLA 口径,排除业务限制)。',
upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。',
goroutines:
'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。',
+23 -18
View File
@@ -9012,14 +9012,6 @@ watch(
0 1px 0 rgb(255 255 255 / 0.9) inset;
}
:global(.dark) .settings-tabs-shell {
border-color: rgb(51 65 85 / 0.65);
background: rgb(15 23 42 / 0.86);
box-shadow:
0 16px 36px rgb(0 0 0 / 0.28),
0 1px 0 rgb(255 255 255 / 0.06) inset;
}
.settings-tabs-scroll {
@apply overflow-x-auto;
-ms-overflow-style: none;
@@ -9063,10 +9055,6 @@ watch(
opacity: 1;
}
:global(.dark) .settings-tab::before {
background: linear-gradient(135deg, rgb(30 41 59 / 0.9), rgb(51 65 85 / 0.62));
}
.settings-tab:focus-visible {
@apply ring-2 ring-primary-500/40 ring-offset-2 ring-offset-white dark:ring-offset-dark-900;
}
@@ -9078,12 +9066,6 @@ watch(
0 1px 0 rgb(255 255 255 / 0.92) inset;
}
:global(.dark) .settings-tab-active {
box-shadow:
0 12px 26px rgb(0 0 0 / 0.22),
0 1px 0 rgb(255 255 255 / 0.08) inset;
}
.settings-tab-active::before {
opacity: 0;
}
@@ -9116,3 +9098,26 @@ watch(
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-none;
}
</style>
<style>
/* Dark-mode overrides for the settings tabs shell. Kept in an UNSCOPED block
because Vue's scoped-CSS compiler was dropping the `:global(.dark) ...`
rules in the production build, leaving inactive tabs unreadable on dark. */
.dark .settings-tabs-shell {
border-color: rgb(51 65 85 / 0.65);
background: rgb(15 23 42 / 0.86);
box-shadow:
0 16px 36px rgb(0 0 0 / 0.28),
0 1px 0 rgb(255 255 255 / 0.06) inset;
}
.dark .settings-tab::before {
background: linear-gradient(135deg, rgb(30 41 59 / 0.9), rgb(51 65 85 / 0.62));
}
.dark .settings-tab-active {
box-shadow:
0 12px 26px rgb(0 0 0 / 0.22),
0 1px 0 rgb(255 255 255 / 0.08) inset;
}
</style>
@@ -30,7 +30,11 @@ const colors = computed(() => ({
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const hasData = computed(() => (props.data?.total ?? 0) > 0)
const totalSlaErrors = computed(() =>
(props.data?.items ?? []).reduce((total, item) => total + Number(item.sla || 0), 0)
)
const hasData = computed(() => totalSlaErrors.value > 0)
const state = computed<ChartState>(() => {
if (hasData.value) return 'ready'
@@ -54,7 +58,7 @@ const categories = computed<ErrorCategory[]>(() => {
for (const item of props.data.items || []) {
const code = Number(item.status_code || 0)
const count = Number(item.total || 0)
const count = Number(item.sla || 0)
if (!Number.isFinite(code) || !Number.isFinite(count)) continue
if ([502, 503, 504].includes(code)) upstream += count
@@ -45,9 +45,7 @@ const colors = computed(() => ({
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
}))
const totalRequestErrors = computed(() =>
sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.business_limited_count ?? 0)))
)
const totalRequestErrors = computed(() => sumNumbers(props.points.map((p) => p.error_count_sla ?? 0)))
const totalUpstreamErrors = computed(() =>
sumNumbers(
@@ -0,0 +1,147 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import OpsErrorDistributionChart from '../OpsErrorDistributionChart.vue'
import OpsErrorTrendChart from '../OpsErrorTrendChart.vue'
vi.mock('chart.js', () => ({
Chart: { register: vi.fn() },
ArcElement: {},
CategoryScale: {},
Filler: {},
Legend: {},
LineElement: {},
LinearScale: {},
PointElement: {},
Title: {},
Tooltip: {},
}))
vi.mock('vue-chartjs', async () => {
const { defineComponent } = await import('vue')
return {
Doughnut: defineComponent({
name: 'Doughnut',
props: {
data: { type: Object, required: true },
options: { type: Object, default: () => ({}) },
},
template: '<div class="doughnut-stub" />',
}),
Line: defineComponent({
name: 'LineChartStub',
props: {
data: { type: Object, required: true },
options: { type: Object, default: () => ({}) },
},
template: '<div class="line-stub" />',
}),
}
})
vi.mock('../../utils/opsFormatters', () => ({
formatHistoryLabel: (date: string | undefined) => date ?? '',
sumNumbers: (values: Array<number | null | undefined>) =>
values.reduce<number>((total, value) => total + (typeof value === 'number' && Number.isFinite(value) ? value : 0), 0),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
const HelpTooltipStub = defineComponent({
name: 'HelpTooltip',
props: {
content: { type: String, default: '' },
},
template: '<span class="help-tooltip-stub" />',
})
const EmptyStateStub = defineComponent({
name: 'EmptyState',
props: {
title: { type: String, default: '' },
description: { type: String, default: '' },
},
template: '<div class="empty-state-stub" />',
})
const globalStubs = {
stubs: {
HelpTooltip: HelpTooltipStub,
EmptyState: EmptyStateStub,
},
}
describe('Ops SLA-scoped error charts', () => {
it('错误分布图按 SLA 错误数统计,不把业务限制错误算进请求错误分布', () => {
const wrapper = mount(OpsErrorDistributionChart, {
props: {
loading: false,
data: {
total: 10,
items: [
{ status_code: 400, total: 7, sla: 2, business_limited: 5 },
{ status_code: 503, total: 3, sla: 0, business_limited: 3 },
],
},
},
global: globalStubs,
})
const doughnut = wrapper.findComponent({ name: 'Doughnut' })
expect(doughnut.exists()).toBe(true)
expect(doughnut.props('data')).toMatchObject({
labels: ['admin.ops.client'],
datasets: [{ data: [2] }],
})
})
it('错误分布图在只有业务限制错误时显示为空态', () => {
const wrapper = mount(OpsErrorDistributionChart, {
props: {
loading: false,
data: {
total: 4,
items: [{ status_code: 500, total: 4, sla: 0, business_limited: 4 }],
},
},
global: globalStubs,
})
expect(wrapper.findComponent({ name: 'Doughnut' }).exists()).toBe(false)
expect(wrapper.find('.empty-state-stub').exists()).toBe(true)
})
it('错误趋势图的请求错误详情按钮只按 SLA 错误启用', () => {
const wrapper = mount(OpsErrorTrendChart, {
props: {
loading: false,
timeRange: '1h',
points: [
{
bucket_start: '2026-05-18T00:00:00Z',
error_count_total: 5,
business_limited_count: 5,
error_count_sla: 0,
upstream_error_count_excl_429_529: 0,
upstream_429_count: 0,
upstream_529_count: 0,
},
],
},
global: globalStubs,
})
const requestErrorsButton = wrapper.findAll('button')[0]
expect(requestErrorsButton.attributes('disabled')).toBeDefined()
})
})