Merge branch 'main' into rebuild/auth-identity-foundation

This commit is contained in:
IanShaw027
2026-04-22 00:35:34 +08:00
67 changed files with 1550 additions and 613 deletions

View File

@@ -284,6 +284,16 @@ const hasError = computed(() => {
return props.account.status === 'error'
})
const isQuotaExceeded = computed(() => {
const exceeded = (used?: number | null, limit?: number | null) =>
typeof limit === 'number' && limit > 0 && typeof used === 'number' && used >= limit
return (
exceeded(props.account.quota_used, props.account.quota_limit) ||
exceeded(props.account.quota_daily_used, props.account.quota_daily_limit) ||
exceeded(props.account.quota_weekly_used, props.account.quota_weekly_limit)
)
})
// Computed: countdown text for rate limit (429)
const rateLimitCountdown = computed(() => {
return formatCountdown(props.account.rate_limit_reset_at)
@@ -307,19 +317,16 @@ const statusClass = computed(() => {
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (props.account.status !== 'active') {
return props.account.status === 'error' ? 'badge-danger' : 'badge-gray'
}
if (isQuotaExceeded.value) {
return 'badge-warning'
}
if (!props.account.schedulable) {
return 'badge-gray'
}
switch (props.account.status) {
case 'active':
return 'badge-success'
case 'inactive':
return 'badge-gray'
case 'error':
return 'badge-danger'
default:
return 'badge-gray'
}
return 'badge-success'
})
// Computed: status text
@@ -330,6 +337,12 @@ const statusText = computed(() => {
if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable')
}
if (props.account.status !== 'active') {
return t(`admin.accounts.status.${props.account.status}`)
}
if (isQuotaExceeded.value) {
return t('admin.accounts.status.quotaExceeded')
}
if (!props.account.schedulable) {
return t('admin.accounts.status.paused')
}

View File

@@ -52,6 +52,10 @@
v-model="editApiKey"
type="password"
class="input font-mono"
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
:placeholder="
account.platform === 'openai'
? 'sk-proj-...'

View File

@@ -166,7 +166,7 @@
<input
type="number"
step="0.001"
min="0"
min="0.001"
autocomplete="off"
:value="entry.rate_multiplier"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"

View File

@@ -81,7 +81,7 @@
<input
type="number"
step="0.001"
min="0"
min="0.001"
:value="config.customRate ?? ''"
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
:placeholder="String(config.defaultRate)"
@@ -139,7 +139,7 @@
<input
type="number"
step="0.001"
min="0"
min="0.001"
:value="config.customRate ?? ''"
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
:placeholder="String(config.defaultRate)"

View File

@@ -617,66 +617,6 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
const openaiModels = {
'gpt-5-codex': {
name: 'GPT-5 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex': {
name: 'GPT-5.1 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex-max': {
name: 'GPT-5.1 Codex Max',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.1-codex-mini': {
name: 'GPT-5.1 Codex Mini',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {}
}
},
'gpt-5.2': {
name: 'GPT-5.2',
limit: {
@@ -725,22 +665,6 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {}
}
},
'gpt-5.4-nano': {
name: 'GPT-5.4 Nano',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.3-codex-spark': {
name: 'GPT-5.3 Codex Spark',
limit: {
@@ -773,22 +697,6 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {}
}
},
'gpt-5.2-codex': {
name: 'GPT-5.2 Codex',
limit: {
context: 400000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'codex-mini-latest': {
name: 'Codex Mini',
limit: {

View File

@@ -17,7 +17,7 @@ vi.mock('@/composables/useClipboard', () => ({
import UseKeyModal from '../UseKeyModal.vue'
describe('UseKeyModal', () => {
it('renders updated GPT-5.4 mini/nano names in OpenCode config', async () => {
it('renders GPT-5.4 mini entry in OpenCode config', async () => {
const wrapper = mount(UseKeyModal, {
props: {
show: true,
@@ -48,6 +48,6 @@ describe('UseKeyModal', () => {
const codeBlock = wrapper.find('pre code')
expect(codeBlock.exists()).toBe(true)
expect(codeBlock.text()).toContain('"name": "GPT-5.4 Mini"')
expect(codeBlock.text()).toContain('"name": "GPT-5.4 Nano"')
expect(codeBlock.text()).not.toContain('"name": "GPT-5.4 Nano"')
})
})

View File

@@ -88,13 +88,24 @@
v-model="config[field.key]"
rows="3"
class="input font-mono text-xs"
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
spellcheck="false"
:placeholder="editing ? t('admin.accounts.leaveEmptyToKeep') : ''"
/>
<div v-else-if="field.sensitive" class="relative">
<input
:type="visibleFields[field.key] ? 'text' : 'password'"
v-model="config[field.key]"
class="input pr-10"
:placeholder="field.defaultValue || ''"
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
spellcheck="false"
:placeholder="editing ? t('admin.accounts.leaveEmptyToKeep') : (field.defaultValue || '')"
/>
<button
type="button"
@@ -398,9 +409,12 @@ function handleSave() {
emitValidationError(t('admin.settings.payment.validationNameRequired'))
return
}
// Validate required config fields — all non-optional fields must be filled
// Validate required config fields — all non-optional fields must be filled.
// In edit mode, sensitive fields may be left blank to preserve the stored
// value (backend merges blanks by preserving the existing secret).
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
if (f.optional) continue
if (props.editing && f.sensitive) continue
const val = (config[f.key] || '').trim()
if (!val) {
const label = f.label || t(`admin.settings.payment.field_${f.key}`)
@@ -412,8 +426,6 @@ function handleSave() {
const filteredConfig: Record<string, string> = {}
for (const [k, v] of Object.entries(config)) {
if (!v || !v.trim()) continue
// Skip masked values — backend keeps existing credentials
if (v === '••••••••') continue
filteredConfig[k] = v
}
@@ -470,7 +482,8 @@ function loadProvider(provider: ProviderInstance) {
form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
// Pre-fill config from API response. Backend omits sensitive fields entirely,
// so those inputs stay blank — submitting blank preserves the stored secret.
if (provider.config) {
for (const [k, v] of Object.entries(provider.config)) {
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl

View File

@@ -78,8 +78,8 @@ import Icon from '@/components/icons/Icon.vue'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
@@ -147,7 +147,7 @@ function getLogoForType(): string | null {
function reopenPopup() {
if (props.payUrl) {
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures())
}
}
@@ -222,7 +222,7 @@ async function handleCancel() {
cleanup()
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
cancelling.value = false
}

View File

@@ -124,8 +124,8 @@ import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
@@ -200,7 +200,7 @@ function isSuccessStatus(status: string | null | undefined): boolean {
function reopenPopup() {
if (props.payUrl) {
const win = window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
const win = window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures())
if (!win || win.closed) {
window.location.href = props.payUrl
}
@@ -257,7 +257,7 @@ async function handleCancel() {
cleanup()
setOutcome('cancelled')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
cancelling.value = false
}

View File

@@ -67,10 +67,10 @@
import { ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { extractApiErrorMessage } from '@/utils/apiError'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores'
import { STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import Icon from '@/components/icons/Icon.vue'
@@ -132,7 +132,7 @@ onMounted(async () => {
selectedType.value = event.value.type
})
} catch (err: unknown) {
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
} finally {
loading.value = false
}
@@ -151,7 +151,7 @@ async function handlePay() {
amount: String(props.payAmount),
},
}).href
const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES)
const popup = window.open(popupUrl, 'paymentPopup', getPaymentPopupFeatures())
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
@@ -186,7 +186,7 @@ async function handlePay() {
emit('success')
}
} catch (err: unknown) {
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
} finally {
submitting.value = false
}
@@ -199,7 +199,7 @@ async function handleCancel() {
await paymentAPI.cancelOrder(props.orderId)
emit('back')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
cancelling.value = false
}

View File

@@ -43,11 +43,24 @@ export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct',
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_POPUP = 'popup'
/** Window features for payment popup windows */
export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes'
/** Preferred popup size for payment gateways. Alipay's standard checkout
* (QR + account login panel) needs ~1200×900 to render without any scrolling. */
const PAYMENT_POPUP_PREFERRED_WIDTH = 1250
const PAYMENT_POPUP_PREFERRED_HEIGHT = 900
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes'
/** Build a window.open features string sized to fit within the current screen
* while preferring the above dimensions. Centers the popup on the available
* work area so nothing is clipped on smaller laptop displays. */
export function getPaymentPopupFeatures(): string {
const screen = typeof window !== 'undefined' ? window.screen : null
const availW = screen?.availWidth ?? PAYMENT_POPUP_PREFERRED_WIDTH
const availH = screen?.availHeight ?? PAYMENT_POPUP_PREFERRED_HEIGHT
const width = Math.min(PAYMENT_POPUP_PREFERRED_WIDTH, availW - 40)
const height = Math.min(PAYMENT_POPUP_PREFERRED_HEIGHT, availH - 40)
const left = Math.max(0, Math.floor((availW - width) / 2))
const top = Math.max(0, Math.floor((availH - height) / 2))
return `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
}
/** Webhook paths for each provider (relative to origin). */
export const WEBHOOK_PATHS: Record<string, string> = {
@@ -87,9 +100,9 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
{ key: 'mchId', label: '', sensitive: false },
{ key: 'privateKey', label: '', sensitive: true },
{ key: 'apiV3Key', label: '', sensitive: true },
{ key: 'certSerial', label: '', sensitive: false },
{ key: 'publicKey', label: '', sensitive: true },
{ key: 'publicKeyId', label: '', sensitive: false },
{ key: 'certSerial', label: '', sensitive: false },
{ key: 'h5AppName', label: '', sensitive: false, optional: true },
{ key: 'h5AppUrl', label: '', sensitive: false, optional: true },
],