mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-11 05:30:19 +08:00
feat(frontend): refine global model pricing dialog
This commit is contained in:
@@ -4,20 +4,20 @@
|
||||
:title="isEditMode ? '编辑模型' : '创建统一模型'"
|
||||
:description="isEditMode ? '修改模型配置和价格信息' : ''"
|
||||
:icon="isEditMode ? SquarePen : Layers"
|
||||
size="4xl"
|
||||
:size="isEditMode ? '4xl' : '3xl'"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div
|
||||
class="flex gap-4"
|
||||
:class="isEditMode ? '' : 'h-[600px]'"
|
||||
:class="isEditMode ? '' : 'h-[600px] flex-col'"
|
||||
>
|
||||
<!-- 左侧:模型选择(仅创建模式) -->
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="w-[260px] shrink-0 flex flex-col h-full"
|
||||
<!-- 上方:搜索和加载预设(仅创建模式) -->
|
||||
<section
|
||||
v-if="!isEditMode && !presetPanelCollapsed"
|
||||
class="h-full flex flex-col space-y-3"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative mb-3">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
@@ -27,84 +27,243 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表(两级结构) -->
|
||||
<div class="flex-1 overflow-y-auto border rounded-lg min-h-0 scrollbar-thin">
|
||||
<!-- 横向提供商 Logo 与模型列表 -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden border rounded-lg flex flex-col">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
class="flex items-center justify-center flex-1"
|
||||
>
|
||||
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- 提供商分组 -->
|
||||
<!-- 提供商 Logo 横向选择 -->
|
||||
<div
|
||||
v-for="group in groupedModels"
|
||||
:key="group.providerId"
|
||||
class="border-b last:border-b-0"
|
||||
v-if="groupedModels.length > 0"
|
||||
class="relative shrink-0 border-b"
|
||||
>
|
||||
<!-- 提供商标题行 -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-muted text-sm"
|
||||
@click="toggleProvider(group.providerId)"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute left-1 top-1/2 z-10 h-8 w-8 -translate-y-1/2 bg-background/95 shadow-sm"
|
||||
title="向左滚动"
|
||||
aria-label="向左滚动提供商"
|
||||
@click="scrollProviderLogos(-1)"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0"
|
||||
:class="expandedProvider === group.providerId ? 'rotate-90' : ''"
|
||||
/>
|
||||
<img
|
||||
:src="getProviderLogoUrl(group.providerId)"
|
||||
:alt="group.providerName"
|
||||
class="w-4 h-4 rounded shrink-0 dark:invert dark:brightness-90"
|
||||
@error="handleLogoError"
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
ref="providerLogoScroller"
|
||||
class="mx-11 flex gap-2 overflow-x-auto p-2 scrollbar-hide"
|
||||
>
|
||||
<button
|
||||
v-for="group in groupedModels"
|
||||
:key="group.providerId"
|
||||
type="button"
|
||||
class="w-[76px] shrink-0 rounded-md border px-2 py-1.5 flex flex-col items-center gap-1 transition-colors"
|
||||
:class="expandedProvider === group.providerId
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-transparent hover:border-border hover:bg-muted'"
|
||||
:title="`${group.providerName}(${group.models.length})`"
|
||||
@click="toggleProvider(group.providerId)"
|
||||
>
|
||||
<span class="truncate font-medium text-xs flex-1">{{ group.providerName }}</span>
|
||||
<span class="text-[10px] text-muted-foreground shrink-0">{{ group.models.length }}</span>
|
||||
<img
|
||||
:src="getProviderLogoUrl(group.providerId)"
|
||||
:alt="group.providerName"
|
||||
class="w-7 h-7 rounded object-contain dark:invert dark:brightness-90"
|
||||
@error="handleLogoError"
|
||||
>
|
||||
<span class="w-full truncate text-[10px] font-medium text-center">{{ group.providerName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 模型列表 -->
|
||||
<div
|
||||
v-if="expandedProvider === group.providerId"
|
||||
class="bg-muted/30"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute right-1 top-1/2 z-10 h-8 w-8 -translate-y-1/2 bg-background/95 shadow-sm"
|
||||
title="向右滚动"
|
||||
aria-label="向右滚动提供商"
|
||||
@click="scrollProviderLogos(1)"
|
||||
>
|
||||
<div
|
||||
v-for="item in group.models"
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 当前提供商模型 -->
|
||||
<div
|
||||
v-if="expandedProviderGroup"
|
||||
class="flex-1 min-h-0 overflow-y-auto p-2 scrollbar-thin"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="item in expandedProviderGroup.models"
|
||||
:key="item.modelId"
|
||||
class="flex flex-col gap-0.5 pl-7 pr-2.5 py-1.5 cursor-pointer text-xs border-t"
|
||||
type="button"
|
||||
class="group relative flex min-h-[152px] min-w-0 flex-col rounded-xl border bg-card p-4 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
|
||||
:class="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'"
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border/70'"
|
||||
@click="selectModel(item)"
|
||||
>
|
||||
<span class="truncate font-medium">{{ item.modelName }}</span>
|
||||
<span
|
||||
class="truncate text-[10px]"
|
||||
v-if="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId"
|
||||
class="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm"
|
||||
>
|
||||
<Check class="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="flex w-full items-start gap-2"
|
||||
:class="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'"
|
||||
>{{ item.modelId }}</span>
|
||||
</div>
|
||||
? 'pr-6'
|
||||
: ''"
|
||||
>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold leading-5">{{ item.modelName }}</span>
|
||||
<span class="block truncate font-mono text-[10px] text-muted-foreground">{{ item.modelId }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="item.family"
|
||||
class="max-w-[88px] shrink-0 truncate rounded-md bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>{{ item.family }}</span>
|
||||
</span>
|
||||
|
||||
<span class="mt-2 flex min-h-5 flex-wrap gap-1">
|
||||
<span
|
||||
v-if="item.supportsReasoning"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-violet-500/20 bg-violet-500/10 px-1.5 py-0.5 text-[9px] font-medium text-violet-700 dark:text-violet-300"
|
||||
>
|
||||
<BrainCircuit class="h-2.5 w-2.5" />推理
|
||||
</span>
|
||||
<span
|
||||
v-if="item.supportsVision"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-[9px] font-medium text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<Eye class="h-2.5 w-2.5" />视觉
|
||||
</span>
|
||||
<span
|
||||
v-if="item.supportsToolCall"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<Wrench class="h-2.5 w-2.5" />工具
|
||||
</span>
|
||||
<span
|
||||
v-if="item.supportsStructuredOutput"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<Braces class="h-2.5 w-2.5" />结构化
|
||||
</span>
|
||||
<span
|
||||
v-if="item.supportsEmbedding"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-fuchsia-500/20 bg-fuchsia-500/10 px-1.5 py-0.5 text-[9px] font-medium text-fuchsia-700 dark:text-fuchsia-300"
|
||||
>
|
||||
<Database class="h-2.5 w-2.5" />Embedding
|
||||
</span>
|
||||
<span
|
||||
v-if="item.openWeights"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-muted/70 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>
|
||||
<PackageOpen class="h-2.5 w-2.5" />开放权重
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="mt-auto flex w-full items-end justify-between gap-2 border-t border-border/60 pt-2 text-[9px] text-muted-foreground">
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span v-if="item.contextLimit">上下文 {{ formatTokenLimit(item.contextLimit) }}</span>
|
||||
<span v-else>上下文未知</span>
|
||||
<span v-if="item.outputLimit">输出 {{ formatTokenLimit(item.outputLimit) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="item.inputPrice !== undefined || item.outputPrice !== undefined"
|
||||
class="shrink-0 text-right font-medium text-foreground/70"
|
||||
>
|
||||
<span class="block">输入 ${{ formatModelPrice(item.inputPrice) }}/M</span>
|
||||
<span class="block">输出 ${{ formatModelPrice(item.outputPrice) }}/M</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.releaseDate"
|
||||
class="shrink-0"
|
||||
>{{ item.releaseDate }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="groupedModels.length === 0"
|
||||
class="text-center py-8 text-sm text-muted-foreground"
|
||||
v-else-if="groupedModels.length > 0"
|
||||
class="flex flex-1 items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ searchQuery ? '未找到模型' : '加载中...' }}
|
||||
点击提供商 Logo 展开模型
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ searchQuery ? '未找到模型' : '暂无可用模型' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:表单 -->
|
||||
<!-- 第二步:详细信息表单 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto h-full scrollbar-thin"
|
||||
v-if="isEditMode || presetPanelCollapsed"
|
||||
class="flex-1 min-h-0 overflow-y-auto scrollbar-thin"
|
||||
:class="isEditMode ? 'max-h-[70vh]' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="!isEditMode"
|
||||
class="mb-4 flex items-center gap-3 rounded-lg border bg-muted/20 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
v-if="selectedModel"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-background"
|
||||
>
|
||||
<img
|
||||
:src="getProviderLogoUrl(selectedModel.providerId)"
|
||||
:alt="selectedModel.providerName"
|
||||
class="h-7 w-7 rounded object-contain dark:invert dark:brightness-90"
|
||||
@error="handleLogoError"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-background"
|
||||
>
|
||||
<SquarePen class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ selectedModel ? `已加载 ${selectedModel.providerName} 预设` : '手动填写模式' }}
|
||||
</div>
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ selectedModel ? selectedModel.modelName : '填写模型详细信息' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedModel"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ selectedModel.modelId }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="reopenPresetPanel"
|
||||
>
|
||||
返回选择模型
|
||||
</Button>
|
||||
</div>
|
||||
<form
|
||||
class="space-y-5"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<section class="space-y-3">
|
||||
<section
|
||||
ref="basicInfoSection"
|
||||
class="space-y-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<h4 class="font-medium text-sm">
|
||||
基本信息
|
||||
</h4>
|
||||
@@ -203,137 +362,169 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 border-t border-border/60 pt-3">
|
||||
<Checkbox
|
||||
:checked="isImageGenerationEnabled"
|
||||
class="mt-0.5"
|
||||
@update:checked="setImageGenerationEnabled"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">
|
||||
图片模型
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3">
|
||||
<section class="space-y-3 rounded-lg border bg-card p-4">
|
||||
<h4 class="font-medium text-sm">
|
||||
价格配置
|
||||
选择计费模式
|
||||
</h4>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
/>
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费</Label>
|
||||
<Input
|
||||
:model-value="form.default_price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-24"
|
||||
placeholder="$/次"
|
||||
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
<Tabs
|
||||
v-model="billingMode"
|
||||
@update:model-value="handleBillingModeChange"
|
||||
>
|
||||
<TabsList class="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="token">
|
||||
Token
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="request">
|
||||
按次
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="image">
|
||||
图片
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="video">
|
||||
视频
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TieredPricingEditor
|
||||
v-show="billingMode === 'token' || billingMode === 'image'"
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
class="mt-3"
|
||||
:show-token-pricing="billingMode === 'token'"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
:show-image-editor="billingMode === 'image'"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
|
||||
</div>
|
||||
|
||||
<!-- 视频计费(分辨率 × 时长) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('common')"
|
||||
>
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('sora')"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('veo')"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="addVideoResolutionPriceRow"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
<TabsContent
|
||||
value="request"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
<div class="rounded-lg border bg-muted/20 p-4 space-y-2">
|
||||
<Label class="text-xs">每次请求价格(美元)</Label>
|
||||
<Input
|
||||
:model-value="form.default_price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="max-w-48"
|
||||
placeholder="如 0.01"
|
||||
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按每次 API 请求收取固定费用,可与 Token 计费同时使用。
|
||||
</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => row.price_per_second = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="video"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
根据输出分辨率配置每秒视频价格。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="removeVideoResolutionPriceRow(idx)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('common')"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('sora')"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="fillVideoResolutionPricePreset('veo')"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="addVideoResolutionPriceRow"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => row.price_per_second = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="removeVideoResolutionPriceRow(idx)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
选择一个价格预设或添加自定义分辨率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
@@ -348,6 +539,15 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEditMode && !presetPanelCollapsed"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="enterManualEntryMode"
|
||||
>
|
||||
手动填写
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isEditMode || presetPanelCollapsed"
|
||||
:disabled="submitting || !form.name || !form.display_name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
@@ -358,7 +558,7 @@
|
||||
{{ isEditMode ? '保存' : '添加' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedModel && !isEditMode"
|
||||
v-if="selectedModel && !isEditMode && presetPanelCollapsed"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="clearSelection"
|
||||
@@ -370,12 +570,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import {
|
||||
Loader2, Layers, SquarePen,
|
||||
Search, ChevronRight, Plus, Trash2
|
||||
Search, ChevronLeft, ChevronRight, Plus, Trash2, Check,
|
||||
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen
|
||||
} from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label, Checkbox } from '@/components/ui'
|
||||
import {
|
||||
Dialog, Button, Input, Label, Checkbox,
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
@@ -412,6 +616,7 @@ const emit = defineEmits<{
|
||||
const { success, error: showError } = useToast()
|
||||
const submitting = ref(false)
|
||||
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
|
||||
const basicInfoSection = ref<HTMLElement | null>(null)
|
||||
|
||||
// 模型列表相关
|
||||
const loading = ref(false)
|
||||
@@ -419,6 +624,26 @@ const searchQuery = ref('')
|
||||
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
|
||||
const selectedModel = ref<ModelsDevModelItem | null>(null)
|
||||
const expandedProvider = ref<string | null>(null)
|
||||
const providerLogoScroller = ref<HTMLElement | null>(null)
|
||||
const presetPanelCollapsed = ref(false)
|
||||
const billingMode = ref('token')
|
||||
|
||||
function formatTokenLimit(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Number((value / 1_000).toFixed(1))}K`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatModelPrice(value?: number): string {
|
||||
if (value === undefined) return '-'
|
||||
if (value === 0) return '0'
|
||||
const precision = value < 0.01 ? 4 : value < 1 ? 3 : 2
|
||||
return value.toFixed(precision).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
|
||||
const allModels = computed(() => {
|
||||
@@ -435,6 +660,25 @@ interface ProviderGroup {
|
||||
models: ModelsDevModelItem[]
|
||||
}
|
||||
|
||||
const PROVIDER_PRIORITY_KEYWORDS = [
|
||||
['anthropic', 'claude'],
|
||||
['openai'],
|
||||
['google', 'gemini'],
|
||||
]
|
||||
|
||||
function getProviderPriority(group: ProviderGroup): number {
|
||||
const searchableText = `${group.providerId} ${group.providerName}`.toLowerCase()
|
||||
const priority = PROVIDER_PRIORITY_KEYWORDS.findIndex(keywords => (
|
||||
keywords.some(keyword => searchableText.includes(keyword))
|
||||
))
|
||||
return priority === -1 ? PROVIDER_PRIORITY_KEYWORDS.length : priority
|
||||
}
|
||||
|
||||
function getDefaultProviderId(groups: ProviderGroup[]): string | null {
|
||||
const claudeProvider = groups.find(group => getProviderPriority(group) === 0)
|
||||
return claudeProvider?.providerId ?? groups[0]?.providerId ?? null
|
||||
}
|
||||
|
||||
const groupedModels = computed(() => {
|
||||
let models = allModels.value.filter(m => !m.deprecated)
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
@@ -462,29 +706,40 @@ const groupedModels = computed(() => {
|
||||
// 转换为数组并排序
|
||||
const result = Array.from(groups.values())
|
||||
|
||||
// 如果有搜索词,把提供商名称/ID匹配的排在前面
|
||||
if (searchQuery.value) {
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result.sort((a, b) => {
|
||||
const searchKeywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result.sort((a, b) => {
|
||||
// 搜索时,优先展示提供商名称或 ID 直接匹配的结果
|
||||
if (searchKeywords.length > 0) {
|
||||
const aText = `${a.providerId} ${a.providerName}`.toLowerCase()
|
||||
const bText = `${b.providerId} ${b.providerName}`.toLowerCase()
|
||||
const aProviderMatch = keywords.some(k => aText.includes(k))
|
||||
const bProviderMatch = keywords.some(k => bText.includes(k))
|
||||
const aProviderMatch = searchKeywords.some(keyword => aText.includes(keyword))
|
||||
const bProviderMatch = searchKeywords.some(keyword => bText.includes(keyword))
|
||||
if (aProviderMatch && !bProviderMatch) return -1
|
||||
if (!aProviderMatch && bProviderMatch) return 1
|
||||
return a.providerName.localeCompare(b.providerName)
|
||||
})
|
||||
} else {
|
||||
result.sort((a, b) => a.providerName.localeCompare(b.providerName))
|
||||
}
|
||||
}
|
||||
|
||||
// Claude(Anthropic)、OpenAI、Google 固定排在最前
|
||||
const priorityDifference = getProviderPriority(a) - getProviderPriority(b)
|
||||
if (priorityDifference !== 0) return priorityDifference
|
||||
return a.providerName.localeCompare(b.providerName)
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const expandedProviderGroup = computed(() => (
|
||||
groupedModels.value.find(group => group.providerId === expandedProvider.value) ?? null
|
||||
))
|
||||
|
||||
// 搜索时如果只有一个提供商,自动展开
|
||||
watch(groupedModels, (groups) => {
|
||||
if (expandedProvider.value && !groups.some(group => group.providerId === expandedProvider.value)) {
|
||||
expandedProvider.value = null
|
||||
}
|
||||
if (searchQuery.value && groups.length === 1) {
|
||||
expandedProvider.value = groups[0].providerId
|
||||
} else if (!searchQuery.value && !expandedProvider.value) {
|
||||
expandedProvider.value = getDefaultProviderId(groups)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -493,6 +748,39 @@ function toggleProvider(providerId: string) {
|
||||
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
|
||||
}
|
||||
|
||||
function scrollProviderLogos(direction: -1 | 1) {
|
||||
providerLogoScroller.value?.scrollBy({
|
||||
left: direction * 280,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
function handleBillingModeChange(mode: string) {
|
||||
billingMode.value = mode
|
||||
if (mode === 'image' && !isImageGenerationEnabled.value) {
|
||||
setImageGenerationEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBasicInformation() {
|
||||
nextTick(() => {
|
||||
basicInfoSection.value?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function enterManualEntryMode() {
|
||||
clearSelection()
|
||||
presetPanelCollapsed.value = true
|
||||
scrollToBasicInformation()
|
||||
}
|
||||
|
||||
function reopenPresetPanel() {
|
||||
presetPanelCollapsed.value = false
|
||||
}
|
||||
|
||||
// 阶梯计费配置
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
|
||||
@@ -764,9 +1052,12 @@ async function loadModels() {
|
||||
}
|
||||
|
||||
// 打开对话框时加载数据
|
||||
watch(() => props.open, (isOpen) => {
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && !props.model) {
|
||||
loadModels()
|
||||
await loadModels()
|
||||
if (!expandedProvider.value) {
|
||||
expandedProvider.value = getDefaultProviderId(groupedModels.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -804,6 +1095,13 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
if (model.supportsEmbedding) {
|
||||
setEmbeddingEnabled(true)
|
||||
}
|
||||
if (model.outputModalities?.includes('image')) {
|
||||
billingMode.value = 'image'
|
||||
} else if (model.outputModalities?.includes('video')) {
|
||||
billingMode.value = 'video'
|
||||
} else {
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
loadVideoPricingFromConfig()
|
||||
|
||||
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
|
||||
@@ -817,6 +1115,9 @@ function selectModel(model: ModelsDevModelItem) {
|
||||
} else {
|
||||
tieredPricing.value = null
|
||||
}
|
||||
|
||||
presetPanelCollapsed.value = true
|
||||
scrollToBasicInformation()
|
||||
}
|
||||
|
||||
// 清除选择(手动填写)
|
||||
@@ -825,6 +1126,7 @@ function clearSelection() {
|
||||
selectedModel.value = null
|
||||
form.value = defaultForm()
|
||||
tieredPricing.value = null
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
|
||||
// Logo 加载失败处理
|
||||
@@ -842,6 +1144,8 @@ function resetForm() {
|
||||
searchQuery.value = ''
|
||||
selectedModel.value = null
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
|
||||
// 加载模型数据(编辑模式)
|
||||
@@ -852,6 +1156,7 @@ function loadModelData() {
|
||||
selectedModel.value = null
|
||||
searchQuery.value = ''
|
||||
expandedProvider.value = null
|
||||
presetPanelCollapsed.value = false
|
||||
|
||||
const modelTieredPricing = props.model.default_tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||
@@ -872,6 +1177,15 @@ function loadModelData() {
|
||||
// 确保 tieredPricing 也被正确设置或重置
|
||||
tieredPricing.value = modelTieredPricing
|
||||
loadVideoPricingFromConfig()
|
||||
if (videoResolutionPrices.value.length > 0) {
|
||||
billingMode.value = 'video'
|
||||
} else if (isImageGenerationEnabled.value) {
|
||||
billingMode.value = 'image'
|
||||
} else if (form.value.default_price_per_request !== undefined) {
|
||||
billingMode.value = 'request'
|
||||
} else {
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
|
||||
@@ -1,158 +1,160 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- 阶梯列表 -->
|
||||
<div
|
||||
v-for="(tier, index) in localTiers"
|
||||
:key="index"
|
||||
class="p-3 border rounded-lg bg-muted/20 space-y-3"
|
||||
>
|
||||
<!-- 阶梯头部 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<template v-if="index < localTiers.length - 1">
|
||||
<template v-if="customInputMode[index]">
|
||||
<Input
|
||||
v-model="customInputValue[index]"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-7 w-20 text-sm"
|
||||
placeholder="K"
|
||||
@keyup.enter="confirmCustomInput(index)"
|
||||
@blur="confirmCustomInput(index)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">K</span>
|
||||
</template>
|
||||
<select
|
||||
v-else
|
||||
:value="getSelectValue(index)"
|
||||
class="h-7 px-2 text-sm border rounded bg-background"
|
||||
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="opt in getAvailableThresholds(index)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="font-medium"
|
||||
>无上限</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs text-muted-foreground"
|
||||
@click="toggleCachePriceMode(index)"
|
||||
>
|
||||
<Repeat2 class="w-3.5 h-3.5 mr-1" />
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '具体价格' : '输入倍率' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="localTiers.length > 1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格输入 -->
|
||||
<template v-if="showTokenPricing !== false">
|
||||
<!-- 阶梯列表 -->
|
||||
<div
|
||||
class="grid grid-cols-4 gap-3"
|
||||
v-for="(tier, index) in localTiers"
|
||||
:key="index"
|
||||
class="p-3 border rounded-lg bg-muted/20 space-y-3"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输入 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.input_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输出 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.output_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '缓存创建(输入倍率)' : '缓存创建 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheCreationEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheCreation(index, v)"
|
||||
/>
|
||||
<!-- 阶梯头部 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<template v-if="index < localTiers.length - 1">
|
||||
<template v-if="customInputMode[index]">
|
||||
<Input
|
||||
v-model="customInputValue[index]"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-7 w-20 text-sm"
|
||||
placeholder="K"
|
||||
@keyup.enter="confirmCustomInput(index)"
|
||||
@blur="confirmCustomInput(index)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">K</span>
|
||||
</template>
|
||||
<select
|
||||
v-else
|
||||
:value="getSelectValue(index)"
|
||||
class="h-7 px-2 text-sm border rounded bg-background"
|
||||
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="opt in getAvailableThresholds(index)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
v-else
|
||||
class="font-medium"
|
||||
>无上限</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs text-muted-foreground"
|
||||
@click="toggleCachePriceMode(index)"
|
||||
>
|
||||
<Repeat2 class="w-3.5 h-3.5 mr-1" />
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="localTiers.length > 1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '缓存读取(输入倍率)' : '缓存读取 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
|
||||
<!-- 价格输入 -->
|
||||
<div
|
||||
class="grid grid-cols-4 gap-3"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输入 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="getCacheReadEditorValue(index)"
|
||||
:model-value="tier.input_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheRead(index, v)"
|
||||
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输出 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.output_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheCreationEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheCreation(index, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheReadEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheRead(index, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加阶梯按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addTier"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
<!-- 添加阶梯按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addTier"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="showImagePricing"
|
||||
v-if="showImagePricing && showImageEditor !== false"
|
||||
class="rounded-lg border bg-muted/10 p-3 space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -288,7 +290,7 @@
|
||||
|
||||
<!-- 验证提示 -->
|
||||
<p
|
||||
v-if="validationError"
|
||||
v-if="showTokenPricing !== false && validationError"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ validationError }}
|
||||
@@ -326,7 +328,9 @@ type CacheMultiplierDraft = {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: TieredPricingConfig | null
|
||||
showTokenPricing?: boolean
|
||||
showImagePricing?: boolean
|
||||
showImageEditor?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TieredPricingConfig | null]
|
||||
@@ -352,6 +356,7 @@ const THRESHOLD_OPTIONS = [
|
||||
{ value: 64000, label: '64K' },
|
||||
{ value: 128000, label: '128K' },
|
||||
{ value: 200000, label: '200K' },
|
||||
{ value: 272000, label: '272K' },
|
||||
{ value: 500000, label: '500K' },
|
||||
{ value: 1000000, label: '1M' },
|
||||
{ value: -1, label: '自定义...' }, // 特殊值表示自定义输入
|
||||
|
||||
Reference in New Issue
Block a user