Merge remote-tracking branch 'origin/main' into feature/batch-image-foundation

# Conflicts:
#	.github/audit-exceptions.yml
This commit is contained in:
Turtle_Li
2026-07-07 10:53:17 +08:00
33 changed files with 2201 additions and 337 deletions
+3 -10
View File
@@ -789,11 +789,7 @@
<!-- 图片生成计费配置 -->
<div
v-if="
createForm.platform === 'antigravity' ||
createForm.platform === 'gemini' ||
createForm.platform === 'openai'
"
v-if="supportsImagePricingPlatform(createForm.platform)"
class="border-t pt-4"
>
<label
@@ -2180,11 +2176,7 @@
<!-- 图片生成计费配置 -->
<div
v-if="
editForm.platform === 'antigravity' ||
editForm.platform === 'gemini' ||
editForm.platform === 'openai'
"
v-if="supportsImagePricingPlatform(editForm.platform)"
class="border-t pt-4"
>
<label
@@ -3320,6 +3312,7 @@ import {
} from "./groupsModelsList";
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
import { supportsImagePricingPlatform } from "./groupsImagePricing";
const { t } = useI18n();
const appStore = useAppStore();
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import {
imagePricingPlatforms,
supportsImagePricingPlatform,
} from "../groupsImagePricing";
describe("groups image pricing platform support", () => {
it("includes Grok media groups", () => {
expect(supportsImagePricingPlatform("grok")).toBe(true);
expect(imagePricingPlatforms.has("grok")).toBe(true);
});
it("keeps non-media group platforms out of the image pricing controls", () => {
expect(supportsImagePricingPlatform("anthropic")).toBe(false);
});
});
@@ -0,0 +1,9 @@
export const imagePricingPlatforms = new Set([
"antigravity",
"gemini",
"grok",
"openai",
]);
export const supportsImagePricingPlatform = (platform: string): boolean =>
imagePricingPlatforms.has(platform);
@@ -67,7 +67,7 @@
</div>
<!-- Plan Edit Dialog -->
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" :payment-config="paymentConfig" @close="showPlanDialog = false" @saved="loadPlans" />
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
</AppLayout>
@@ -78,6 +78,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
import type { AdminPaymentConfig } from '@/api/admin/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import adminAPI from '@/api/admin'
import type { SubscriptionPlan } from '@/types/payment'
@@ -97,6 +98,7 @@ const appStore = useAppStore()
// ==================== Groups ====================
const groups = ref<AdminGroup[]>([])
const paymentConfig = ref<AdminPaymentConfig | null>(null)
async function loadGroups() {
try {
@@ -104,6 +106,13 @@ async function loadGroups() {
} catch { /* ignore */ }
}
async function loadPaymentConfig() {
try {
const res = await adminPaymentAPI.getConfig()
paymentConfig.value = res.data
} catch { /* preview only */ }
}
function getGroup(id: number): AdminGroup | undefined {
return groups.value.find(g => g.id === id)
}
@@ -181,6 +190,7 @@ async function handleDeletePlan() {
onMounted(() => {
loadGroups()
loadPaymentConfig()
loadPlans()
})
</script>
@@ -35,7 +35,16 @@
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required /></div>
<div>
<label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label>
<input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required />
<p v-if="subscriptionCnyPreview" class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">
{{ t('payment.admin.subscriptionCnyPayPreview', { amount: subscriptionCnyPreview.amount }) }}
<span v-if="subscriptionCnyPreview.feeRate > 0">
{{ t('payment.admin.subscriptionCnyPayPreviewWithFee', { feeRate: subscriptionCnyPreview.feeRate, total: subscriptionCnyPreview.total }) }}
</span>
</p>
</div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
@@ -81,7 +90,9 @@ import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
import type { AdminPaymentConfig } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { formatPaymentAmount } from '@/components/payment/currency'
import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
@@ -94,6 +105,7 @@ const props = defineProps<{
show: boolean
plan: SubscriptionPlan | null
groups: AdminGroup[]
paymentConfig?: AdminPaymentConfig | null
}>()
const emit = defineEmits<{
@@ -129,6 +141,31 @@ const selectedGroupInfo = computed(() => {
return props.groups.find(g => g.id === planForm.group_id) || null
})
function roundCnyAmount(value: number): number {
return Math.round(value * 100) / 100
}
function ceilCnyAmount(value: number): number {
return Math.ceil(value * 100) / 100
}
const subscriptionCnyPreview = computed(() => {
const price = Number(planForm.price) || 0
const rate = Number(props.paymentConfig?.subscription_usd_to_cny_rate) || 0
if (price <= 0 || rate <= 0) return null
const amount = roundCnyAmount(price * rate)
const feeRate = Number(props.paymentConfig?.recharge_fee_rate) || 0
const fee = feeRate > 0 ? ceilCnyAmount((amount * feeRate) / 100) : 0
const total = feeRate > 0 ? roundCnyAmount(amount + fee) : amount
return {
amount: formatPaymentAmount(amount, 'CNY'),
feeRate,
total: formatPaymentAmount(total, 'CNY'),
}
})
// Reset form when dialog opens
watch(() => props.show, (visible) => {
if (!visible) return
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PlanEditDialog from '../PlanEditDialog.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'payment.admin.subscriptionCnyPayPreview') return `preview ${params?.amount}`
if (key === 'payment.admin.subscriptionCnyPayPreviewWithFee') return `fee ${params?.feeRate} ${params?.total}`
return key
},
}),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
vi.mock('@/api/admin/payment', () => ({
adminPaymentAPI: {
createPlan: vi.fn(),
updatePlan: vi.fn(),
},
}))
function mountDialog(paymentConfig: Record<string, unknown> | null) {
return mount(PlanEditDialog, {
props: {
show: true,
plan: null,
groups: [],
paymentConfig,
},
global: {
stubs: {
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
},
Select: true,
Icon: true,
GroupBadge: true,
},
},
})
}
describe('PlanEditDialog subscription CNY payment preview', () => {
it('shows CNY channel charge using the configured subscription rate and fee', async () => {
const wrapper = mountDialog({
subscription_usd_to_cny_rate: 7.15,
recharge_fee_rate: 2.5,
})
await wrapper.find('input[type="number"]').setValue('9.99')
expect(wrapper.text()).toContain('preview')
expect(wrapper.text()).toContain('¥71.43')
expect(wrapper.text()).toContain('fee 2.5')
expect(wrapper.text()).toContain('¥73.22')
})
it('hides the preview when the subscription rate is not configured', async () => {
const wrapper = mountDialog({
subscription_usd_to_cny_rate: 0,
recharge_fee_rate: 2.5,
})
await wrapper.find('input[type="number"]').setValue('9.99')
expect(wrapper.text()).not.toContain('preview')
expect(wrapper.text()).not.toContain('¥71.43')
})
})