feat: add OpenAI image generation controls

This commit is contained in:
2ue
2026-05-05 03:26:54 +08:00
parent 4de28fec8c
commit 6faa344916
85 changed files with 6086 additions and 568 deletions
@@ -291,9 +291,23 @@
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === BILLING_MODE_IMAGE">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
<span class="font-medium text-sky-300">${{ imageUnitPrice(tooltipData).toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageTotalPrice') }}</span>
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === BILLING_MODE_IMAGE ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
@@ -360,6 +374,13 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
return Number.isNaN(result) ? 0 : result
}
function imageUnitPrice(row: AdminUsageLog | null): number {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
+9 -1
View File
@@ -844,6 +844,8 @@ export default {
perMillionTokens: '/ 1M tokens',
unitPrice: 'Per-request price',
imageUnitPrice: 'Per-image price',
imageTotalPrice: 'Image total price',
imageCount: 'Image count',
cacheRead: 'Read',
cacheWrite: 'Write',
serviceTier: 'Service tier',
@@ -2050,7 +2052,13 @@ export default {
},
imagePricing: {
title: 'Image Generation Pricing',
description: 'Configure pricing for image generation models. Leave empty to use default prices.'
description: 'Configure image generation access and base image prices. Leave empty to use default prices.',
allowImageGeneration: 'Allow image generation for this group',
independentMultiplier: 'Use independent image multiplier',
imageMultiplier: 'Image multiplier',
modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.',
finalPricePreview: 'Final per-image price preview',
notConfigured: 'Not configured'
},
claudeCode: {
title: 'Claude Code Client Restriction',
+9 -1
View File
@@ -848,6 +848,8 @@ export default {
perMillionTokens: '/ 1M Token',
unitPrice: '单次价格',
imageUnitPrice: '单张价格',
imageTotalPrice: '图片总价',
imageCount: '图片张数',
cacheRead: '读取',
cacheWrite: '写入',
serviceTier: '服务档位',
@@ -2133,7 +2135,13 @@ export default {
},
imagePricing: {
title: '图片生成计费',
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
description: '配置图片生成能力和图片基础单价,留空则使用默认价格',
allowImageGeneration: '允许当前分组生图',
independentMultiplier: '生图倍率独立',
imageMultiplier: '生图独立倍率',
modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。',
finalPricePreview: '最终单张价格预览',
notConfigured: '未配置'
},
claudeCode: {
title: 'Claude Code 客户端限制',
+10 -1
View File
@@ -492,7 +492,10 @@ export interface Group {
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
// 图片生成计费配置(仅 antigravity 平台使用)
// 图片生成计费配置
allow_image_generation: boolean
image_rate_independent: boolean
image_rate_multiplier: number
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
@@ -602,6 +605,9 @@ export interface CreateGroupRequest {
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
allow_image_generation?: boolean
image_rate_independent?: boolean
image_rate_multiplier?: number
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
@@ -627,6 +633,9 @@ export interface UpdateGroupRequest {
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
allow_image_generation?: boolean
image_rate_independent?: boolean
image_rate_multiplier?: number
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
+186 -2
View File
@@ -666,6 +666,40 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.imagePricing.description") }}
</p>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.allow_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowImageGeneration") }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.image_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.independentMultiplier") }}
</label>
</div>
<div
v-if="createForm.image_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t("admin.groups.imagePricing.imageMultiplier")
}}</label>
<input
v-model.number="createForm.image_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
@@ -701,6 +735,22 @@
/>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.modeHint") }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t("admin.groups.imagePricing.finalPricePreview") }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in createImageFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
@@ -1801,6 +1851,40 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.imagePricing.description") }}
</p>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.allow_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowImageGeneration") }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.image_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.independentMultiplier") }}
</label>
</div>
<div
v-if="editForm.image_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t("admin.groups.imagePricing.imageMultiplier")
}}</label>
<input
v-model.number="editForm.image_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
@@ -1836,6 +1920,22 @@
/>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.modeHint") }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t("admin.groups.imagePricing.finalPricePreview") }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in editImageFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
@@ -3009,7 +3109,10 @@ const createForm = reactive({
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
// 图片生成计费配置(仅 antigravity 平台使用)
// 图片生成计费配置
allow_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
@@ -3291,7 +3394,10 @@ const editForm = reactive({
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
// 图片生成计费配置(仅 antigravity 平台使用)
// 图片生成计费配置
allow_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
@@ -3321,6 +3427,62 @@ const editForm = reactive({
rpm_limit: 0 as number,
});
type ImagePricingFormState = {
rate_multiplier: number;
image_rate_independent: boolean;
image_rate_multiplier: number;
image_price_1k: number | string | null;
image_price_2k: number | string | null;
image_price_4k: number | string | null;
};
const imagePricingTiers = [
{ key: "image_price_1k", label: "1K" },
{ key: "image_price_2k", label: "2K" },
{ key: "image_price_4k", label: "4K" },
] as const;
const normalizePreviewNumber = (value: number | string | null | undefined, fallback = 0) => {
if (value === null || value === undefined || value === "") {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const formatImagePricePreview = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === "") {
return t("admin.groups.imagePricing.notConfigured");
}
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return t("admin.groups.imagePricing.notConfigured");
}
return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
};
const buildImageFinalPricePreview = (form: ImagePricingFormState) => {
const multiplier = form.image_rate_independent
? normalizePreviewNumber(form.image_rate_multiplier, 1)
: normalizePreviewNumber(form.rate_multiplier, 1);
return imagePricingTiers.map((tier) => {
const basePrice = normalizePreviewNumber(form[tier.key]);
return {
label: tier.label,
value: basePrice > 0
? formatImagePricePreview(basePrice * multiplier)
: t("admin.groups.imagePricing.notConfigured"),
};
});
};
const createImageFinalPricePreview = computed(() =>
buildImageFinalPricePreview(createForm),
);
const editImageFinalPricePreview = computed(() =>
buildImageFinalPricePreview(editForm),
);
// 根据分组类型返回不同的删除确认消息
const deleteConfirmMessage = computed(() => {
if (!deletingGroup.value) {
@@ -3479,6 +3641,9 @@ const closeCreateModal = () => {
createForm.daily_limit_usd = null;
createForm.weekly_limit_usd = null;
createForm.monthly_limit_usd = null;
createForm.allow_image_generation = false;
createForm.image_rate_independent = false;
createForm.image_rate_multiplier = 1;
createForm.image_price_1k = null;
createForm.image_price_2k = null;
createForm.image_price_4k = null;
@@ -3513,6 +3678,16 @@ const normalizeOptionalLimit = (
return Number.isFinite(value) && value > 0 ? value : null;
};
const normalizeImageRateMultiplier = (
value: number | string | null | undefined,
): number => {
if (value === null || value === undefined || value === "") {
return 1;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1;
};
const handleCreateGroup = async () => {
if (!createForm.name.trim()) {
appStore.showError(t("admin.groups.nameRequired"));
@@ -3551,6 +3726,9 @@ const handleCreateGroup = async () => {
requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd);
requestData.weekly_limit_usd = emptyToNull(requestData.weekly_limit_usd);
requestData.monthly_limit_usd = emptyToNull(requestData.monthly_limit_usd);
requestData.image_rate_multiplier = normalizeImageRateMultiplier(
requestData.image_rate_multiplier,
);
await adminAPI.groups.create(requestData);
appStore.showSuccess(t("admin.groups.groupCreated"));
closeCreateModal();
@@ -3582,6 +3760,9 @@ const handleEdit = async (group: AdminGroup) => {
editForm.daily_limit_usd = group.daily_limit_usd;
editForm.weekly_limit_usd = group.weekly_limit_usd;
editForm.monthly_limit_usd = group.monthly_limit_usd;
editForm.allow_image_generation = group.allow_image_generation ?? false;
editForm.image_rate_independent = group.image_rate_independent ?? false;
editForm.image_rate_multiplier = group.image_rate_multiplier ?? 1;
editForm.image_price_1k = group.image_price_1k;
editForm.image_price_2k = group.image_price_2k;
editForm.image_price_4k = group.image_price_4k;
@@ -3676,6 +3857,9 @@ const handleUpdateGroup = async () => {
payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd);
payload.weekly_limit_usd = emptyToNull(payload.weekly_limit_usd);
payload.monthly_limit_usd = emptyToNull(payload.monthly_limit_usd);
payload.image_rate_multiplier = normalizeImageRateMultiplier(
payload.image_rate_multiplier,
);
await adminAPI.groups.update(editingGroup.value.id, payload);
appStore.showSuccess(t("admin.groups.groupUpdated"));
closeEditModal();
+23 -2
View File
@@ -459,9 +459,23 @@
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<template v-else-if="tooltipData?.billing_mode === 'image'">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }} ({{ tooltipData.image_size || '2K' }})</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
<span class="font-medium text-sky-300">${{ imageUnitPrice(tooltipData).toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageTotalPrice') }}</span>
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</template>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
@@ -625,6 +639,13 @@ const formatDuration = (ms: number): string => {
return `${(ms / 1000).toFixed(2)}s`
}
const imageUnitPrice = (row: UsageLog | null): number => {
if (!row || row.image_count <= 0) return 0
const total = row.total_cost ?? 0
const price = total / row.image_count
return Number.isFinite(price) ? price : 0
}
const formatUserAgent = (ua: string): string => {
return ua
}