feat: add OpenAI image generation controls
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 客户端限制',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user