refactor(models): 重构模型页面列表与详情排版

把客户端模型改为单行账本列表,目录信息、价格范围与路由分组下沉到
可折叠详情,消除多套价格下重复渲染的模型元数据。

- 新增 ModelCollection / ModelDetailPanel / ModelCatalogFacts /
  ModelSummaryStrip 与 model-presenter,替换 ModelTree* 树状组件
- 目录元数据改为规格标签值加少量能力 chip,取代成排标签堆叠
- 上游层复用 catalog_summary 渲染一次目录信息,不再逐价格重复
- 概览改为指标卡片条,目录状态与未使用价格记录归入其中
- 价格单位说明由整块提示降级为页脚小字
This commit is contained in:
tbphp committed 2026-08-06 23:55:38 +08:00
1 parent ff7d0eee26
commit c79cedea49
12 files changed
+1422 -1107

No files matched your search

@@ -235,7 +235,9 @@ onBeforeUnmount(clearRequest)
<li v-for="group in affectedGroups" :key="group.id">
<strong>{{ group.name }}</strong>
<span :data-enabled="group.enabled">
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
{{
group.enabled ? t('models.detail.groupEnabled') : t('models.detail.groupDisabled')
}}
</span>
</li>
</ul>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ModelCatalogReferenceDto } from '@/app/resources/models'
import { formatInteger } from '@/lib/format'
const props = defineProps<{
reference: ModelCatalogReferenceDto
}>()
const { locale, t } = useI18n()
interface CatalogSpec {
key: string
label: string
value: string
}
const metadata = computed(() => props.reference.model)
const sourceLabel = computed(() =>
t(`models.detail.catalogReference.${props.reference.source}`, {
provider: props.reference.provider_name,
}),
)
const specs = computed<CatalogSpec[]>(() => {
const model = metadata.value
const entries: CatalogSpec[] = []
const push = (key: string, value: string): void => {
if (value) entries.push({ key, label: t(`models.detail.specs.${key}`), value })
}
push(
'context',
model.limits.context === null ? '' : formatInteger(model.limits.context, locale.value),
)
push(
'maxOutput',
model.limits.output === null ? '' : formatInteger(model.limits.output, locale.value),
)
push('modalities', formatModalities())
push('knowledge', model.knowledge)
push('released', model.release_date)
push('family', model.family)
return entries
})
const capabilities = computed(() =>
(['reasoning', 'tool_call', 'structured_output', 'attachment', 'temperature'] as const)
.filter((capability) => metadata.value.capabilities[capability] === true)
.map((capability) => t(`models.detail.capabilities.${capability}`))
.concat(metadata.value.open_weights === true ? [t('models.detail.openWeights')] : []),
)
function formatModalities(): string {
const { input, output } = metadata.value.modalities
if (input.length === 0 && output.length === 0) return ''
const arrow = output.length > 0 ? `→ ${output.join(' / ')}` : ''
return `${input.join(' / ')} ${arrow}`.trim()
}
</script>
<template>
<div class="model-catalog">
<p class="model-catalog__source">
<span>{{ sourceLabel }}</span>
<strong>{{ metadata.name }}</strong>
</p>
<p v-if="metadata.description" class="model-catalog__description">
{{ metadata.description }}
</p>
<dl v-if="specs.length > 0" class="model-catalog__specs">
<div v-for="spec in specs" :key="spec.key">
<dt>{{ spec.label }}</dt>
<dd>{{ spec.value }}</dd>
</div>
</dl>
<ul v-if="capabilities.length > 0" class="model-catalog__capabilities">
<li v-for="capability in capabilities" :key="capability">{{ capability }}</li>
</ul>
</div>
</template>
<style scoped>
.model-catalog {
display: grid;
min-width: 0;
gap: var(--space-2-5);
}
.model-catalog__source {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
margin: 0;
}
.model-catalog__source span {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-catalog__source strong {
font-size: var(--text-meta);
font-weight: 600;
}
.model-catalog__description {
max-width: 92ch;
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
line-height: var(--line-normal);
}
.model-catalog__specs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(118px, max-content));
gap: var(--space-2) var(--space-6);
margin: 0;
}
.model-catalog__specs div {
display: grid;
min-width: 0;
gap: 2px;
}
.model-catalog__specs dt {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-catalog__specs dd {
margin: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: var(--text-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-catalog__capabilities {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.model-catalog__capabilities li {
border-radius: var(--radius-tag);
background: var(--color-tag);
color: var(--color-text-muted);
padding: 2px 7px;
font-size: var(--text-label-xs);
}
</style>
+457
View File
@@ -0,0 +1,457 @@
<script setup lang="ts">
import { ChevronDown, Pencil } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ClientModelDto, ModelPriceBranchDto } from '@/app/resources/models'
import { groupDetailLocation } from '@/app/route-locations'
import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
import CopyButton from '@/components/ui/CopyButton.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelDetailPanel from './ModelDetailPanel.vue'
import {
presentClientModel,
type ClientModelPresentation,
type ModelPriceFieldSummary,
} from './model-presenter'
const visibleGroupCount = 3
const props = defineProps<{
items: ClientModelDto[]
}>()
const emit = defineEmits<{
edit: [branch: ModelPriceBranchDto, trigger: HTMLElement]
}>()
const { t } = useI18n()
const expanded = ref(new Set<string>())
const presentations = computed<ClientModelPresentation[]>(() =>
props.items.map((model) => presentClientModel(model)),
)
watch(
() => props.items,
(items) => {
const available = new Set(items.map(({ client_model: model }) => model))
expanded.value = new Set([...expanded.value].filter((model) => available.has(model)))
},
)
function toggle(clientModel: string): void {
const next = new Set(expanded.value)
if (!next.delete(clientModel)) next.add(clientModel)
expanded.value = next
}
function upstreamCaption(presentation: ClientModelPresentation): string {
const { shape, modelID, count } = presentation.upstream
if (shape === 'multi') return t('models.collection.multiUpstream', { count })
if (shape === 'alias') return t('models.collection.alias', { model: modelID })
return t('models.collection.direct')
}
function priceText(summary: ModelPriceFieldSummary): string {
if (summary.state === 'unavailable') return t('modelPrices.values.unavailable')
if (summary.state === 'free') return t('modelPrices.values.free')
const value = t('modelPrices.values.configured', { value: summary.value })
if (summary.state === 'single') return value
return t('models.collection.priceRange', {
min: value,
max: t('modelPrices.values.configured', { value: summary.upper }),
})
}
function scopeCaption(presentation: ClientModelPresentation): string | null {
if (presentation.scopes.length < 2) return null
return presentation.pendingCount > 0
? t('models.collection.pendingScopes', {
pending: presentation.pendingCount,
count: presentation.scopes.length,
})
: t('models.collection.scopeCount', { count: presentation.scopes.length })
}
function forwardEdit(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
emit('edit', branch, trigger)
}
</script>
<template>
<LedgerRecordList
:label="t('models.collection.label')"
grid-class="models-record-grid"
:scroll-hint="t('models.collection.scrollHint')"
>
<template #header>
<span role="columnheader">{{ t('models.collection.columns.model') }}</span>
<span role="columnheader">{{ t('models.collection.columns.routing') }}</span>
<span role="columnheader">{{ t('models.collection.columns.prices') }}</span>
<span role="columnheader">{{ t('models.collection.columns.status') }}</span>
<span role="columnheader">
<span class="sr-only">{{ t('models.collection.columns.actions') }}</span>
</span>
</template>
<template v-for="presentation in presentations" :key="presentation.model.client_model">
<article
class="ledger-record-list__record model-record"
:class="{ 'model-record--expanded': expanded.has(presentation.model.client_model) }"
role="row"
>
<div class="ledger-record-list__cell model-record__identity" role="cell">
<div class="model-record__name">
<strong>{{ presentation.model.client_model }}</strong>
<CopyButton
class="model-record__copy"
:value="presentation.model.client_model"
:label="t('models.collection.copy', { model: presentation.model.client_model })"
:success-label="t('models.collection.copySucceeded')"
:failure-label="t('models.collection.copyFailed')"
/>
</div>
<span class="model-record__caption">{{ upstreamCaption(presentation) }}</span>
</div>
<div class="ledger-record-list__cell model-record__routing" role="cell">
<ul class="model-record__groups">
<li
v-for="group in presentation.routeGroups.slice(0, visibleGroupCount)"
:key="group.id"
>
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
</li>
<li
v-if="presentation.routeGroups.length > visibleGroupCount"
class="model-record__groups-more"
>
{{
t('models.collection.moreGroups', {
count: presentation.routeGroups.length - visibleGroupCount,
})
}}
</li>
</ul>
<ul class="model-record__protocols">
<li v-for="protocol in presentation.model.protocols" :key="protocol">
{{ t(`common.protocols.${protocol}`) }}
</li>
</ul>
</div>
<div class="ledger-record-list__cell model-record__prices" role="cell">
<div v-for="summary in [presentation.input, presentation.output]" :key="summary.field">
<span>{{ t(`modelPrices.fields.${summary.field}`) }}</span>
<strong :data-state="summary.state" :title="priceText(summary)">
{{ priceText(summary) }}
</strong>
</div>
</div>
<div class="ledger-record-list__cell model-record__status" role="cell">
<StatusBadge
size="compact"
:tone="presentation.status === 'configured' ? 'success' : 'warning'"
>
{{ t(`modelPrices.status.${presentation.status}`) }}
</StatusBadge>
<small v-if="scopeCaption(presentation)">{{ scopeCaption(presentation) }}</small>
</div>
<div class="ledger-record-list__cell model-record__actions" role="cell">
<IconButton
v-if="presentation.soleBranch"
variant="ghost"
tone="action"
size="compact"
:label="t('modelPrices.edit.open', { model: presentation.soleBranch.price.model_id })"
@click="forwardEdit(presentation.soleBranch, $event.currentTarget as HTMLElement)"
>
<Pencil :size="15" aria-hidden="true" />
</IconButton>
<IconButton
class="model-record__toggle"
variant="ghost"
size="compact"
:pressed="expanded.has(presentation.model.client_model)"
:label="
expanded.has(presentation.model.client_model)
? t('models.collection.collapse', { model: presentation.model.client_model })
: t('models.collection.expand', { model: presentation.model.client_model })
"
@click="toggle(presentation.model.client_model)"
>
<ChevronDown :size="16" aria-hidden="true" />
</IconButton>
</div>
</article>
<div
v-if="expanded.has(presentation.model.client_model)"
class="model-record__detail"
role="row"
>
<div class="model-record__detail-cell" role="cell">
<ModelDetailPanel :presentation="presentation" @edit="forwardEdit" />
</div>
</div>
</template>
</LedgerRecordList>
</template>
<style scoped>
.models-record-grid {
--ledger-record-list-grid: minmax(210px, 1.5fr) minmax(180px, 1.35fr) minmax(190px, 1.15fr)
minmax(112px, 132px) 84px;
--ledger-record-list-column-gap: var(--space-4);
--ledger-record-list-record-min-height: 64px;
--ledger-record-list-record-padding: var(--space-2-5) 0;
}
.model-record--expanded {
background: var(--color-surface-sunken);
}
.model-record__identity {
display: grid;
align-content: center;
gap: 2px;
}
.model-record__name {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
}
.model-record__name strong {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: var(--text-meta);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-record__copy {
flex: none;
opacity: 0;
transition: opacity var(--duration-fast) var(--easing-standard);
}
.model-record:hover .model-record__copy,
.model-record__copy:focus-within {
opacity: 1;
}
.model-record__caption {
overflow: hidden;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-record__routing {
display: grid;
align-content: center;
gap: var(--space-1);
}
.model-record__groups,
.model-record__protocols {
display: flex;
min-width: 0;
align-items: baseline;
flex-wrap: wrap;
gap: 2px var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.model-record__groups {
font-size: var(--text-sm);
}
.model-record__groups a {
color: var(--color-action);
font-weight: 600;
}
.model-record__groups-more {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-record__protocols {
gap: 2px var(--space-1);
}
.model-record__protocols li {
border-radius: var(--radius-tag);
background: var(--color-tag);
color: var(--color-text-faint);
padding: 1px 6px;
font-size: var(--text-label-xs);
white-space: nowrap;
}
.model-record__prices {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: center;
gap: var(--space-2);
}
.model-record__prices > div {
min-width: 0;
border-left: 1px solid var(--color-border-subtle);
padding-left: var(--space-2-5);
}
.model-record__prices > div:first-child {
border-left: 0;
padding-left: 0;
}
.model-record__prices span {
display: block;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-record__prices strong {
display: block;
overflow: hidden;
margin-top: 1px;
font-family: var(--font-mono);
font-size: var(--text-meta);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-record__prices strong[data-state='unavailable'] {
color: var(--color-text-faint);
font-family: var(--font-sans);
font-weight: 500;
}
.model-record__prices strong[data-state='free'] {
color: var(--color-success);
}
.model-record__status {
display: grid;
justify-items: start;
align-content: center;
gap: 3px;
}
.model-record__status small {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-record__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-0-5);
}
.model-record__toggle :deep(svg) {
transition: transform var(--duration-fast) var(--easing-standard);
}
.model-record__toggle[aria-pressed='true'] :deep(svg) {
transform: rotate(180deg);
}
.model-record__detail {
grid-column: 1 / -1;
background: var(--color-surface-sunken);
}
.model-record__detail-cell {
min-width: 0;
border-top: 1px dashed var(--color-border-control);
padding: var(--space-4) 0 var(--space-5);
animation: model-record-detail var(--duration-normal) var(--easing-data);
}
@keyframes model-record-detail {
from {
opacity: 0;
transform: translateY(-3px);
}
}
@media (prefers-reduced-motion: reduce) {
.model-record__detail-cell {
animation: none;
}
}
@media (max-width: 1120px) {
.models-record-grid {
--ledger-record-list-grid: minmax(180px, 1.4fr) minmax(150px, 1.2fr) minmax(170px, 1.1fr)
minmax(104px, 120px) 76px;
--ledger-record-list-column-gap: var(--space-3);
}
}
@media (max-width: 860px) {
.models-record-grid {
--ledger-record-list-card-grid: minmax(0, 1fr) auto;
}
.model-record__identity {
grid-column: 1;
grid-row: 1;
}
.model-record__actions {
grid-column: 2;
grid-row: 1;
}
.model-record__copy {
opacity: 1;
}
.model-record__routing {
grid-column: 1 / -1;
grid-row: 2;
}
.model-record__prices {
grid-column: 1 / -1;
grid-row: 3;
border-top: 1px solid var(--color-border-subtle);
padding-top: var(--space-3);
}
.model-record__status {
grid-column: 1 / -1;
grid-row: 4;
}
.model-record__detail {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
padding: 0 var(--space-4);
}
.model-record__detail-cell {
border-top: 0;
padding-block: var(--space-4);
}
}
</style>
@@ -0,0 +1,359 @@
<script setup lang="ts">
import { Pencil } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import type { ModelPriceBranchDto, ModelUpstreamDto } from '@/app/resources/models'
import { groupDetailLocation } from '@/app/route-locations'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelPriceResetDialog from '@/features/model-prices/ModelPriceResetDialog.vue'
import { presentModelPrice } from '@/features/model-prices/model-price-presenter'
import ModelCatalogFacts from './ModelCatalogFacts.vue'
import { hasWiderPriceImpact, type ClientModelPresentation } from './model-presenter'
const props = defineProps<{
presentation: ClientModelPresentation
}>()
const emit = defineEmits<{
edit: [branch: ModelPriceBranchDto, trigger: HTMLElement]
}>()
const { t } = useI18n()
function slots(branch: ModelPriceBranchDto) {
return presentModelPrice(branch.price, {
unavailable: t('modelPrices.values.unavailable'),
free: t('modelPrices.values.free'),
configured: (value) => t('modelPrices.values.configured', { value }),
}).slots
}
function methodLabel(branch: ModelPriceBranchDto): string {
const { method, matched_provider_id: provider } = branch.price
if (method === null) return t('modelPrices.method.pending')
if (method === 'auto_matched') return t('modelPrices.method.auto_matched', { provider })
return t(`modelPrices.method.${method}`)
}
function showUpstreamHeader(upstream: ModelUpstreamDto): boolean {
return props.presentation.upstream.shape !== 'direct' || upstream.alias_applied
}
function edit(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
emit('edit', branch, trigger)
}
</script>
<template>
<div class="model-detail">
<section
v-for="upstream in presentation.model.upstream_models"
:key="upstream.model_id"
class="model-detail__upstream"
>
<header v-if="showUpstreamHeader(upstream)" class="model-detail__upstream-head">
<span class="model-detail__eyebrow">{{ t('models.detail.upstreamModel') }}</span>
<strong>{{ upstream.model_id }}</strong>
<span class="model-detail__chip">
{{ upstream.alias_applied ? t('models.detail.aliasApplied') : t('models.detail.direct') }}
</span>
</header>
<ModelCatalogFacts v-if="upstream.catalog_summary" :reference="upstream.catalog_summary" />
<article v-for="branch in upstream.prices" :key="branch.price.id" class="model-detail__scope">
<div class="model-detail__scope-head">
<div class="model-detail__scope-identity">
<span class="model-detail__eyebrow">
{{ t(`models.detail.scope.${branch.price.scope.kind}`) }}
</span>
<strong>{{ branch.price.scope.label }}</strong>
</div>
<StatusBadge
size="compact"
:tone="branch.price.pricing_status === 'configured' ? 'success' : 'warning'"
>
{{ t(`modelPrices.status.${branch.price.pricing_status}`) }}
</StatusBadge>
<div class="model-detail__scope-actions">
<IconButton
variant="ghost"
tone="action"
size="compact"
:label="t('modelPrices.edit.open', { model: branch.price.model_id })"
@click="edit(branch, $event.currentTarget as HTMLElement)"
>
<Pencil :size="15" aria-hidden="true" />
</IconButton>
<ModelPriceResetDialog
v-if="branch.price.can_reset"
:row="branch.price"
action="reset"
/>
</div>
</div>
<ModelCatalogFacts
v-if="!upstream.catalog_summary && branch.catalog_reference"
:reference="branch.catalog_reference"
/>
<dl class="model-detail__slots">
<div v-for="slot in slots(branch)" :key="slot.field" :data-state="slot.state">
<dt>{{ t(`modelPrices.fields.${slot.field}`) }}</dt>
<dd>{{ slot.value }}</dd>
</div>
</dl>
<p class="model-detail__facts">
<span>{{ methodLabel(branch) }}</span>
<span>
{{
t('modelPrices.references', {
entries: branch.price.reference_count,
groups: branch.price.reference_group_count,
})
}}
</span>
<span v-if="branch.price.partial" class="model-detail__facts-warning">
{{ t('modelPrices.facts.partial') }}
</span>
<span v-if="branch.price.has_context_tiers">{{ t('modelPrices.facts.tiered') }}</span>
</p>
<div class="model-detail__groups">
<span class="model-detail__eyebrow">{{ t('models.detail.routeGroups') }}</span>
<ul>
<li v-for="group in branch.route_groups" :key="group.id">
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
<span v-if="!group.enabled">{{ t('models.detail.groupDisabled') }}</span>
</li>
</ul>
<span v-if="hasWiderPriceImpact(branch)" class="model-detail__impact">
{{ t('models.detail.globalImpact', { count: branch.affected_groups.length }) }}
</span>
</div>
</article>
</section>
</div>
</template>
<style scoped>
.model-detail {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.model-detail__upstream {
display: grid;
min-width: 0;
gap: var(--space-3);
}
.model-detail__upstream-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
}
.model-detail__upstream-head strong {
font-family: var(--font-mono);
font-size: var(--text-meta);
overflow-wrap: anywhere;
}
.model-detail__eyebrow {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
letter-spacing: 0.04em;
}
.model-detail__chip {
border-radius: var(--radius-tag);
background: var(--color-tag);
color: var(--color-text-muted);
padding: 2px 7px;
font-size: var(--text-label-xs);
}
.model-detail__scope {
display: grid;
min-width: 0;
gap: var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
background: var(--color-surface);
padding: var(--space-3-5) var(--space-4);
}
.model-detail__scope-head {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-3);
}
.model-detail__scope-identity {
display: grid;
min-width: 0;
gap: 1px;
}
.model-detail__scope-identity strong {
overflow: hidden;
font-size: var(--text-meta);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-detail__scope-actions {
display: flex;
align-items: center;
gap: var(--space-0-5);
}
.model-detail__slots {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
margin: 0;
border-block: 1px solid var(--color-border-subtle);
padding-block: var(--space-2-5);
}
.model-detail__slots div {
min-width: 0;
border-left: 1px solid var(--color-border-subtle);
padding-left: var(--space-2-5);
}
.model-detail__slots div:first-child {
border-left: 0;
padding-left: 0;
}
.model-detail__slots dt {
overflow: hidden;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-detail__slots dd {
margin: var(--space-0-5) 0 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: var(--text-meta);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-detail__slots div[data-state='unavailable'] dd {
color: var(--color-text-faint);
font-family: var(--font-sans);
font-weight: 500;
}
.model-detail__slots div[data-state='free'] dd {
color: var(--color-success);
}
.model-detail__facts {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2-5);
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.model-detail__facts span + span::before {
margin-right: var(--space-2-5);
color: var(--color-text-faint);
content: '·';
}
.model-detail__facts-warning {
color: var(--color-warning);
}
.model-detail__groups {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
min-width: 0;
}
.model-detail__groups ul {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.model-detail__groups li {
display: inline-flex;
align-items: baseline;
gap: var(--space-1);
font-size: var(--text-sm);
}
.model-detail__groups a {
color: var(--color-action);
font-weight: 600;
}
.model-detail__groups li span {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-detail__impact {
color: var(--color-warning);
font-size: var(--text-label-xs);
font-weight: 600;
}
@media (max-width: 720px) {
.model-detail__scope {
padding: var(--space-3) var(--space-3);
}
.model-detail__scope-head {
grid-template-columns: minmax(0, 1fr) auto;
}
.model-detail__scope-actions {
grid-column: 2;
grid-row: 1;
}
.model-detail__scope-head :deep(.status-badge) {
grid-column: 1 / -1;
grid-row: 2;
justify-self: start;
}
.model-detail__slots {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2-5) var(--space-2);
}
.model-detail__slots div:nth-child(3) {
border-left: 0;
padding-left: 0;
}
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ModelCollectionCatalogDto, ModelCollectionSummaryDto } from '@/app/resources/models'
import { modelPricesLocation } from '@/app/route-locations'
import AppDateTime from '@/components/ui/AppDateTime.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatInteger } from '@/lib/format'
const props = defineProps<{
summary: ModelCollectionSummaryDto
catalog: ModelCollectionCatalogDto
}>()
const { locale, t } = useI18n()
const stats = computed(() => [
{
key: 'clientModels',
value: props.summary.client_model_count,
tone: 'accent' as const,
},
{
key: 'upstreamModels',
value: props.summary.upstream_model_count,
tone: 'plain' as const,
},
{
key: 'prices',
value: props.summary.price_count,
tone: 'plain' as const,
},
{
key: 'pendingPrices',
value: props.summary.pending_price_count,
tone: props.summary.pending_price_count > 0 ? ('warning' as const) : ('plain' as const),
},
])
const catalogTone = computed(() => {
if (!props.catalog.available) return 'neutral' as const
return props.catalog.error_code ? ('warning' as const) : ('success' as const)
})
const catalogLabel = computed(() => {
if (!props.catalog.available) return t('models.catalog.unavailable')
return props.catalog.error_code ? t('models.catalog.stale') : t('models.catalog.available')
})
</script>
<template>
<section class="models-summary" :aria-label="t('models.summary.label')">
<dl class="models-summary__stats">
<div v-for="stat in stats" :key="stat.key" :data-tone="stat.tone">
<dt>{{ t(`models.summary.${stat.key}`) }}</dt>
<dd>{{ formatInteger(stat.value, locale) }}</dd>
</div>
</dl>
<div class="models-summary__catalog">
<StatusBadge size="compact" :tone="catalogTone">{{ catalogLabel }}</StatusBadge>
<p v-if="catalog.successful_fetch_at_ms > 0">
<span>{{ t('models.catalog.lastSuccess') }}</span>
<AppDateTime :instant="catalog.successful_fetch_at_ms" :locale="locale" />
</p>
<p v-if="catalog.error_code" class="models-summary__catalog-error">
<code>{{ catalog.error_code }}</code>
<template v-if="catalog.checked_at_ms > 0">
<span>{{ t('models.catalog.lastCheck') }}</span>
<AppDateTime :instant="catalog.checked_at_ms" :locale="locale" />
</template>
</p>
<RouterLink
v-if="summary.unreferenced_price_count > 0"
class="models-summary__unreferenced"
:to="modelPricesLocation()"
>
{{
t('models.summary.unreferencedPrices', {
count: formatInteger(summary.unreferenced_price_count, locale),
})
}}
</RouterLink>
</div>
</section>
</template>
<style scoped>
.models-summary {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4) var(--space-6);
}
.models-summary__stats {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
gap: 1px;
margin: 0;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-card);
background: var(--color-border-subtle);
}
.models-summary__stats > div {
min-width: 0;
background: var(--color-surface);
padding: var(--space-3) var(--space-4);
}
.models-summary__stats dt {
overflow: hidden;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
font-weight: 560;
text-overflow: ellipsis;
white-space: nowrap;
}
.models-summary__stats dd {
margin: var(--space-1) 0 0;
font-family: var(--font-mono);
font-size: 22px;
font-weight: 580;
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
line-height: 1.1;
}
.models-summary__stats > div[data-tone='accent'] dd {
color: var(--color-action);
}
.models-summary__stats > div[data-tone='warning'] dd {
color: var(--color-warning);
}
.models-summary__catalog {
display: grid;
justify-items: end;
gap: var(--space-1);
}
.models-summary__catalog p {
display: flex;
align-items: baseline;
gap: var(--space-1);
margin: 0;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.models-summary__catalog-error code {
color: var(--color-warning);
font-size: var(--text-label-xs);
}
.models-summary__unreferenced {
color: var(--color-action);
font-size: var(--text-label-xs);
font-weight: 600;
}
@media (max-width: 980px) {
.models-summary {
grid-template-columns: minmax(0, 1fr);
}
.models-summary__catalog {
justify-items: start;
}
}
@media (max-width: 620px) {
.models-summary__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -1,372 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ClientModelDto, ModelPriceBranchDto, ModelUpstreamDto } from '@/app/resources/models'
import CopyChip from '@/components/ui/CopyChip.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelTreePriceRow from './ModelTreePriceRow.vue'
const props = defineProps<{
items: ClientModelDto[]
}>()
const emit = defineEmits<{
edit: [scope: ModelPriceBranchDto, trigger: HTMLElement]
}>()
const { t } = useI18n()
interface ClientModelPresentation {
model: ClientModelDto
scopes: ModelPriceBranchDto[]
pending: number
simpleScope: ModelPriceBranchDto | null
showUpstreamLayer: boolean
}
const presentations = computed<ClientModelPresentation[]>(() =>
props.items.map((model) => {
const scopes = model.upstream_models.flatMap(({ prices }) => prices)
const pending = scopes.filter(({ price }) => price.pricing_status === 'pending').length
const upstream = model.upstream_models[0]
const simpleScope =
model.upstream_models.length === 1 &&
upstream !== undefined &&
!upstream.alias_applied &&
upstream.prices.length === 1
? (upstream.prices[0] ?? null)
: null
return {
model,
scopes,
pending,
simpleScope,
showUpstreamLayer:
model.upstream_models.length > 1 ||
model.upstream_models.some(({ alias_applied }) => alias_applied),
}
}),
)
function priceSummary(presentation: ClientModelPresentation): string {
return presentation.pending > 0
? t('models.tree.pricesWithPending', {
count: presentation.scopes.length,
pending: presentation.pending,
})
: t('models.tree.prices', { count: presentation.scopes.length })
}
function upstreamSummary(upstream: ModelUpstreamDto): string {
const pending = upstream.prices.filter(({ price }) => price.pricing_status === 'pending').length
return pending > 0
? t('models.tree.pricesWithPending', {
count: upstream.prices.length,
pending,
})
: t('models.tree.prices', { count: upstream.prices.length })
}
function forwardEdit(scope: ModelPriceBranchDto, trigger: HTMLElement): void {
emit('edit', scope, trigger)
}
</script>
<template>
<section class="model-tree-collection" :aria-label="t('models.tree.label')">
<div class="model-tree-collection__header" aria-hidden="true">
<span>{{ t('models.tree.columns.structure') }}</span>
<span>{{ t('models.tree.columns.status') }}</span>
<span>{{ t('models.tree.columns.prices') }}</span>
<span>{{ t('models.tree.columns.facts') }}</span>
</div>
<article
v-for="presentation in presentations"
:key="presentation.model.client_model"
class="model-tree-client"
>
<ModelTreePriceRow
v-if="presentation.simpleScope"
:scope="presentation.simpleScope"
:client-model="presentation.model.client_model"
@edit="forwardEdit"
/>
<template v-else>
<header class="model-tree-client__header">
<div class="model-tree-client__identity">
<span>{{ t('models.tree.clientModel') }}</span>
<CopyChip
:value="presentation.model.client_model"
:label="t('models.tree.copy', { model: presentation.model.client_model })"
:success-label="t('models.tree.copySucceeded')"
:failure-label="t('models.tree.copyFailed')"
/>
</div>
<div class="model-tree-client__protocols">
<span v-for="protocol in presentation.model.protocols" :key="protocol">
{{ t(`common.protocols.${protocol}`) }}
</span>
</div>
<StatusBadge :tone="presentation.pending > 0 ? 'warning' : 'success'" size="compact">
{{ priceSummary(presentation) }}
</StatusBadge>
</header>
<div class="model-tree-client__body">
<section
v-for="upstream in presentation.model.upstream_models"
:key="upstream.model_id"
class="model-tree-upstream"
:class="{
'model-tree-upstream--visible': presentation.showUpstreamLayer,
'model-tree-upstream--direct': !presentation.showUpstreamLayer,
}"
>
<header v-if="presentation.showUpstreamLayer" class="model-tree-upstream__header">
<div>
<span>{{ t('models.tree.upstreamModel') }}</span>
<strong>{{ upstream.model_id }}</strong>
<small v-if="!upstream.alias_applied">{{ t('models.tree.directModel') }}</small>
</div>
<span>{{ upstreamSummary(upstream) }}</span>
</header>
<div class="model-tree-upstream__prices">
<ModelTreePriceRow
v-for="scope in upstream.prices"
:key="scope.price.id"
:scope="scope"
@edit="forwardEdit"
/>
</div>
</section>
</div>
</template>
</article>
</section>
</template>
<style scoped>
.model-tree-collection {
min-width: 0;
border-bottom: 1px solid var(--color-border-control);
}
.model-tree-collection__header {
display: grid;
min-height: 38px;
grid-template-columns:
minmax(300px, 1.45fr) minmax(92px, 0.38fr) minmax(300px, 1.2fr)
minmax(170px, 0.62fr);
align-items: center;
gap: var(--space-4);
border-bottom: 1px solid var(--color-border-control);
color: var(--color-text-faint);
padding-inline: var(--space-2);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.04em;
}
.model-tree-client {
min-width: 0;
border-bottom: 1px solid var(--color-border-control);
}
.model-tree-client:last-child {
border-bottom: 0;
}
.model-tree-client__header {
display: grid;
min-height: 58px;
grid-template-columns: minmax(220px, 1fr) minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
background: var(--color-surface-sunken);
padding: var(--space-2-5) var(--space-2);
}
.model-tree-client__identity {
display: grid;
min-width: 0;
gap: 1px;
}
.model-tree-client__identity > span,
.model-tree-upstream__header span,
.model-tree-upstream__header small {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-tree-client__protocols {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
}
.model-tree-client__protocols span {
border-radius: var(--radius-tag);
background: var(--color-tag);
color: var(--color-text-muted);
padding: 3px 7px;
font-size: var(--text-label-xs);
}
.model-tree-client__body {
padding-left: var(--space-4);
}
.model-tree-upstream {
position: relative;
min-width: 0;
border-left: 1px solid var(--color-border-control);
}
.model-tree-upstream + .model-tree-upstream {
border-top: 1px solid var(--color-border-control);
}
.model-tree-upstream--direct {
border-left: 0;
}
.model-tree-upstream__header {
position: relative;
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-5);
}
.model-tree-upstream__header::before {
position: absolute;
top: 50%;
left: 0;
width: var(--space-3);
border-top: 1px solid var(--color-border-control);
content: '';
}
.model-tree-upstream__header > div {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: baseline;
gap: var(--space-2);
}
.model-tree-upstream__header strong {
overflow: hidden;
font-family: var(--font-mono);
font-size: var(--text-meta);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tree-upstream__header > span {
flex: none;
}
.model-tree-upstream__prices {
padding-left: var(--space-4);
}
.model-tree-upstream--direct .model-tree-upstream__prices {
padding-left: 0;
}
@media (max-width: 1120px) {
.model-tree-collection__header {
grid-template-columns:
minmax(260px, 1.3fr) minmax(88px, 0.35fr) minmax(270px, 1.1fr)
minmax(150px, 0.55fr);
gap: var(--space-2-5);
}
}
@media (max-width: 860px) {
.model-tree-collection {
display: grid;
gap: var(--space-3);
border-bottom: 0;
}
.model-tree-collection__header {
display: none;
}
.model-tree-client {
overflow: hidden;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-control);
background: var(--color-surface);
padding: var(--space-4);
}
.model-tree-client:last-child {
border-bottom: 1px solid var(--color-border-subtle);
}
.model-tree-client__header {
min-height: 0;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2) var(--space-3);
background: transparent;
padding: 0 0 var(--space-3);
}
.model-tree-client__protocols {
grid-column: 1 / -1;
grid-row: 2;
}
.model-tree-client__body {
padding-left: 0;
}
.model-tree-upstream,
.model-tree-upstream--direct {
border-left: 0;
}
.model-tree-upstream + .model-tree-upstream {
margin-top: var(--space-3);
padding-top: var(--space-3);
}
.model-tree-upstream__header {
min-height: 0;
align-items: flex-start;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2-5) 0;
}
.model-tree-upstream__header::before {
display: none;
}
.model-tree-upstream__header > div {
grid-template-columns: auto minmax(0, 1fr);
}
.model-tree-upstream__header small {
grid-column: 2;
}
.model-tree-upstream__prices,
.model-tree-upstream--direct .model-tree-upstream__prices {
padding-left: var(--space-3);
border-left: 1px solid var(--color-border-control);
}
}
</style>
@@ -1,501 +0,0 @@
<script setup lang="ts">
import { Pencil } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ModelCatalogMetadataDto, ModelPriceBranchDto } from '@/app/resources/models'
import { groupDetailLocation } from '@/app/route-locations'
import CopyChip from '@/components/ui/CopyChip.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelPriceResetDialog from '@/features/model-prices/ModelPriceResetDialog.vue'
import { presentModelPrice } from '@/features/model-prices/model-price-presenter'
const props = withDefaults(
defineProps<{
scope: ModelPriceBranchDto
clientModel?: string
}>(),
{ clientModel: undefined },
)
const emit = defineEmits<{
edit: [scope: ModelPriceBranchDto, trigger: HTMLElement]
}>()
const { n, t } = useI18n()
const presentation = computed(() =>
presentModelPrice(props.scope.price, {
unavailable: t('modelPrices.values.unavailable'),
free: t('modelPrices.values.free'),
configured: (value) => t('modelPrices.values.configured', { value }),
}),
)
const catalogMetadata = computed(() => props.scope.catalog_reference?.model ?? null)
const hasDistinctGlobalImpact = computed(() => {
if (props.scope.route_groups.length !== props.scope.affected_groups.length) return true
const routeGroupIDs = new Set(props.scope.route_groups.map(({ id }) => id))
return props.scope.affected_groups.some(({ id }) => !routeGroupIDs.has(id))
})
function metadataFacts(metadata: ModelCatalogMetadataDto): string[] {
const result: string[] = []
if (metadata.family) result.push(t('models.tree.metadata.family', { value: metadata.family }))
if (metadata.status) result.push(t('models.tree.metadata.status', { value: metadata.status }))
if (metadata.limits.context !== null) {
result.push(t('models.tree.metadata.context', { value: n(metadata.limits.context) }))
}
if (metadata.limits.input !== null) {
result.push(t('models.tree.metadata.inputLimit', { value: n(metadata.limits.input) }))
}
if (metadata.limits.output !== null) {
result.push(t('models.tree.metadata.outputLimit', { value: n(metadata.limits.output) }))
}
if (metadata.modalities.input.length > 0) {
result.push(
t('models.tree.metadata.inputModalities', { value: metadata.modalities.input.join(', ') }),
)
}
if (metadata.modalities.output.length > 0) {
result.push(
t('models.tree.metadata.outputModalities', { value: metadata.modalities.output.join(', ') }),
)
}
if (metadata.release_date) {
result.push(t('models.tree.metadata.releaseDate', { value: metadata.release_date }))
}
if (metadata.last_updated) {
result.push(t('models.tree.metadata.lastUpdated', { value: metadata.last_updated }))
}
if (metadata.knowledge) {
result.push(t('models.tree.metadata.knowledge', { value: metadata.knowledge }))
}
if (metadata.open_weights === true) result.push(t('models.tree.metadata.openWeights'))
for (const capability of [
'attachment',
'reasoning',
'tool_call',
'structured_output',
'temperature',
] as const) {
if (metadata.capabilities[capability] === true) {
result.push(t(`models.tree.metadata.capabilities.${capability}`))
}
}
return result
}
function methodLabel(): string {
const row = props.scope.price
if (row.method === null) return t('modelPrices.method.pending')
if (row.method === 'auto_matched') {
return t('modelPrices.method.auto_matched', { provider: row.matched_provider_id })
}
return t(`modelPrices.method.${row.method}`)
}
function edit(trigger: HTMLElement): void {
emit('edit', props.scope, trigger)
}
</script>
<template>
<article class="model-tree-price-row" :class="{ 'model-tree-price-row--root': clientModel }">
<div class="model-tree-price-row__structure">
<div class="model-tree-price-row__identity">
<span class="model-tree-price-row__kind">
{{
clientModel
? t('models.tree.clientModel')
: t(`models.tree.scope.${scope.price.scope.kind}`)
}}
</span>
<CopyChip
v-if="clientModel"
:value="clientModel"
:label="t('models.tree.copy', { model: clientModel })"
:success-label="t('models.tree.copySucceeded')"
:failure-label="t('models.tree.copyFailed')"
/>
<strong v-else>{{ scope.price.scope.label }}</strong>
<small v-if="clientModel">
{{ t(`models.tree.scope.${scope.price.scope.kind}`) }} ·
{{ scope.price.scope.label }}
</small>
</div>
<div v-if="scope.catalog_reference && catalogMetadata" class="model-tree-price-row__catalog">
<span class="model-tree-price-row__catalog-source">
{{
t(`models.tree.catalogReference.${scope.catalog_reference.source}`, {
provider: scope.catalog_reference.provider_name,
})
}}
· {{ catalogMetadata.name }}
</span>
<p v-if="catalogMetadata.description">{{ catalogMetadata.description }}</p>
<div v-if="metadataFacts(catalogMetadata).length" class="model-tree-price-row__metadata">
<span v-for="fact in metadataFacts(catalogMetadata)" :key="fact">{{ fact }}</span>
</div>
</div>
<div class="model-tree-price-row__groups">
<span>
{{
hasDistinctGlobalImpact
? t('models.tree.routeGroups')
: scope.route_groups.length > 1
? t('models.tree.groupCount', { count: scope.route_groups.length })
: t('models.tree.groups')
}}
</span>
<ul
:class="{
'model-tree-price-row__group-list--shared': scope.route_groups.length > 1,
}"
>
<li v-for="group in scope.route_groups" :key="group.id">
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
<small>{{ group.provider_id ?? t('models.tree.customProvider') }}</small>
<small>{{
group.protocols.map((value) => t(`common.protocols.${value}`)).join(' · ')
}}</small>
<span :data-enabled="group.enabled">
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
</span>
</li>
</ul>
<template v-if="hasDistinctGlobalImpact">
<span class="model-tree-price-row__global-impact">
{{ t('models.tree.globalImpact', { count: scope.affected_groups.length }) }}
</span>
<ul class="model-tree-price-row__group-list--shared">
<li v-for="group in scope.affected_groups" :key="group.id">
<RouterLink :to="groupDetailLocation(group.id)">{{ group.name }}</RouterLink>
<span :data-enabled="group.enabled">
{{ group.enabled ? t('models.tree.groupEnabled') : t('models.tree.groupDisabled') }}
</span>
</li>
</ul>
</template>
</div>
</div>
<div class="model-tree-price-row__status">
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.status') }}</span>
<StatusBadge
size="compact"
:tone="scope.price.pricing_status === 'configured' ? 'success' : 'warning'"
>
{{ t(`modelPrices.status.${scope.price.pricing_status}`) }}
</StatusBadge>
<small v-if="scope.price.partial">{{ t('modelPrices.facts.partial') }}</small>
<small v-if="scope.price.has_context_tiers">{{ t('modelPrices.facts.tiered') }}</small>
</div>
<div class="model-tree-price-row__prices">
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.prices') }}</span>
<div
v-for="slot in presentation.slots"
:key="slot.field"
class="model-tree-price-row__price-slot"
:data-state="slot.state"
>
<span>{{ t(`modelPrices.fields.${slot.field}`) }}</span>
<strong>{{ slot.value }}</strong>
</div>
</div>
<div class="model-tree-price-row__facts">
<span class="model-tree-price-row__mobile-label">{{ t('models.tree.columns.facts') }}</span>
<strong>{{ methodLabel() }}</strong>
<small v-if="scope.price.method === 'auto_sync'">
{{ t('modelPrices.reference', { provider: scope.price.matched_provider_id }) }}
</small>
<small>
{{
t('modelPrices.references', {
entries: scope.price.reference_count,
groups: scope.price.reference_group_count,
})
}}
</small>
<div class="model-tree-price-row__actions">
<IconButton
variant="ghost"
tone="action"
size="compact"
:label="t('modelPrices.edit.open', { model: scope.price.model_id })"
@click="edit($event.currentTarget as HTMLElement)"
>
<Pencil :size="15" aria-hidden="true" />
</IconButton>
<ModelPriceResetDialog v-if="scope.price.can_reset" :row="scope.price" action="reset" />
</div>
</div>
</article>
</template>
<style scoped>
.model-tree-price-row {
display: grid;
min-width: 0;
grid-template-columns:
minmax(300px, 1.45fr) minmax(92px, 0.38fr) minmax(300px, 1.2fr)
minmax(170px, 0.62fr);
align-items: stretch;
gap: var(--space-4);
border-top: 1px solid var(--color-border-subtle);
padding: var(--space-3-5) var(--space-2);
}
.model-tree-price-row--root {
border-top: 0;
padding-block: var(--space-4);
}
.model-tree-price-row__structure,
.model-tree-price-row__identity,
.model-tree-price-row__catalog,
.model-tree-price-row__groups,
.model-tree-price-row__status,
.model-tree-price-row__facts {
display: grid;
min-width: 0;
align-content: start;
}
.model-tree-price-row__structure {
gap: var(--space-2-5);
}
.model-tree-price-row__identity {
gap: 2px;
}
.model-tree-price-row__kind,
.model-tree-price-row__catalog-source,
.model-tree-price-row__groups > span,
.model-tree-price-row__identity small,
.model-tree-price-row__status small,
.model-tree-price-row__facts small {
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.model-tree-price-row__identity > strong {
overflow: hidden;
font-family: var(--font-mono);
font-size: var(--text-meta);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tree-price-row__catalog {
gap: var(--space-1);
}
.model-tree-price-row__catalog p {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.model-tree-price-row__metadata {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
}
.model-tree-price-row__metadata span,
.model-tree-price-row__groups li > span {
border-radius: var(--radius-tag);
background: var(--color-tag);
color: var(--color-text-muted);
padding: 2px 6px;
font-size: var(--text-label-xs);
}
.model-tree-price-row__groups {
gap: var(--space-1);
}
.model-tree-price-row__groups > .model-tree-price-row__global-impact {
margin-top: var(--space-1);
color: var(--color-warning);
font-weight: 600;
}
.model-tree-price-row__groups ul {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.model-tree-price-row__groups li {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: 4px 7px;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.model-tree-price-row__groups a {
color: var(--color-action);
font-weight: 600;
}
.model-tree-price-row__groups small {
color: var(--color-text-faint);
}
.model-tree-price-row__groups li > span[data-enabled='false'] {
color: var(--color-text-faint);
}
.model-tree-price-row__status,
.model-tree-price-row__facts {
justify-items: start;
gap: var(--space-1);
}
.model-tree-price-row__prices {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-content: start;
gap: var(--space-2);
}
.model-tree-price-row__price-slot {
min-width: 0;
border-left: 1px solid var(--color-border-subtle);
padding-left: var(--space-2);
}
.model-tree-price-row__price-slot:first-of-type {
border-left: 0;
padding-left: 0;
}
.model-tree-price-row__price-slot > span,
.model-tree-price-row__mobile-label {
display: block;
overflow: hidden;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tree-price-row__price-slot strong {
display: block;
overflow: hidden;
margin-top: var(--space-0-5);
font-family: var(--font-mono);
font-size: var(--text-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.model-tree-price-row__price-slot[data-state='unavailable'] strong {
color: var(--color-text-faint);
font-family: var(--font-sans);
font-weight: 500;
}
.model-tree-price-row__price-slot[data-state='free'] strong {
color: var(--color-success);
}
.model-tree-price-row__facts > strong {
font-size: var(--text-sm);
}
.model-tree-price-row__actions {
display: flex;
align-items: center;
gap: var(--space-0-5);
margin-top: var(--space-1);
}
.model-tree-price-row__mobile-label {
display: none;
}
@media (max-width: 1120px) {
.model-tree-price-row {
grid-template-columns:
minmax(260px, 1.3fr) minmax(88px, 0.35fr) minmax(270px, 1.1fr)
minmax(150px, 0.55fr);
gap: var(--space-2-5);
}
}
@media (max-width: 860px) {
.model-tree-price-row,
.model-tree-price-row--root {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border-subtle);
padding: var(--space-4) 0 0;
}
.model-tree-price-row--root {
border-top: 0;
padding-top: 0;
}
.model-tree-price-row__structure {
grid-column: 1;
grid-row: 1;
}
.model-tree-price-row__status {
grid-column: 2;
grid-row: 1;
justify-self: end;
}
.model-tree-price-row__prices {
grid-column: 1 / -1;
grid-row: 2;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.model-tree-price-row__facts {
grid-column: 1 / -1;
grid-row: 3;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
border-top: 1px solid var(--color-border-subtle);
padding-top: var(--space-2-5);
}
.model-tree-price-row__facts > strong,
.model-tree-price-row__facts > small {
grid-column: 1;
}
.model-tree-price-row__actions {
grid-column: 2;
grid-row: 1 / span 4;
margin-top: 0;
}
.model-tree-price-row__mobile-label {
display: block;
}
.model-tree-price-row__prices > .model-tree-price-row__mobile-label {
grid-column: 1 / -1;
}
}
</style>
+14 -112
View File
@@ -21,7 +21,6 @@ import CollectionFilterBar from '@/components/collection/CollectionFilterBar.vue
import LedgerSheet from '@/components/layout/LedgerSheet.vue'
import PageFrame from '@/components/layout/PageFrame.vue'
import AppButton from '@/components/ui/AppButton.vue'
import AppDateTime from '@/components/ui/AppDateTime.vue'
import AppSearchInput from '@/components/ui/AppSearchInput.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
@@ -29,13 +28,13 @@ import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import PageHeader from '@/components/ui/PageHeader.vue'
import PaginationBar from '@/components/ui/PaginationBar.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModelPriceDrawer from '@/features/model-prices/ModelPriceDrawer.vue'
import ModelTreeCollection from './ModelTreeCollection.vue'
import ModelCollection from './ModelCollection.vue'
import ModelSummaryStrip from './ModelSummaryStrip.vue'
const client = useApiClient()
const { locale, n, t } = useI18n()
const { n, t } = useI18n()
const searchDraft = ref('')
const appliedSearch = ref<string | undefined>()
const groupStatus = ref<ModelCollectionGroupStatus>('enabled')
@@ -81,14 +80,6 @@ const pricingStatusOptions = computed(() =>
label: t(`models.filters.pricingStatus.${value}`),
})),
)
const catalogTone = computed(() => {
if (!data.value?.catalog.available) return 'neutral' as const
return data.value.catalog.error_code ? ('warning' as const) : ('success' as const)
})
const catalogLabel = computed(() => {
if (!data.value?.catalog.available) return t('models.catalog.unavailable')
return data.value.catalog.error_code ? t('models.catalog.stale') : t('models.catalog.available')
})
watch(
[() => data.value?.pagination.total_pages, page, () => modelsQuery.isPlaceholderData.value],
@@ -140,8 +131,8 @@ function resetConditions(): void {
page.value = 1
}
function editScope(scope: ModelPriceBranchDto, trigger: HTMLElement): void {
selected.value = scope
function editBranch(branch: ModelPriceBranchDto, trigger: HTMLElement): void {
selected.value = branch
restoreFocus = trigger
drawerOpen.value = true
}
@@ -211,46 +202,7 @@ onBeforeUnmount(() => searchDebounce.cancel())
@retry="modelsQuery.refetch()"
/>
<InlineFeedback appearance="ledger" tone="neutral">
{{ t('models.context') }}
</InlineFeedback>
<section class="models-page__overview" :aria-label="t('models.summary.label')">
<dl class="models-page__summary">
<div>
<dt>{{ t('models.summary.clientModels') }}</dt>
<dd>{{ n(data.summary.client_model_count) }}</dd>
</div>
<div>
<dt>{{ t('models.summary.upstreamModels') }}</dt>
<dd>{{ n(data.summary.upstream_model_count) }}</dd>
</div>
<div>
<dt>{{ t('models.summary.prices') }}</dt>
<dd>{{ n(data.summary.price_count) }}</dd>
</div>
<div>
<dt>{{ t('models.summary.pendingPrices') }}</dt>
<dd>{{ n(data.summary.pending_price_count) }}</dd>
</div>
<div>
<dt>{{ t('models.summary.unreferencedPrices') }}</dt>
<dd>{{ n(data.summary.unreferenced_price_count) }}</dd>
</div>
</dl>
<div class="models-page__catalog-status">
<StatusBadge size="compact" :tone="catalogTone">{{ catalogLabel }}</StatusBadge>
<span v-if="data.catalog.successful_fetch_at_ms > 0">
{{ t('models.catalog.lastSuccess') }}
<AppDateTime :instant="data.catalog.successful_fetch_at_ms" :locale="locale" />
</span>
<span v-if="data.catalog.checked_at_ms > 0">
{{ t('models.catalog.lastCheck') }}
<AppDateTime :instant="data.catalog.checked_at_ms" :locale="locale" />
</span>
<code v-if="data.catalog.error_code">{{ data.catalog.error_code }}</code>
</div>
</section>
<ModelSummaryStrip :summary="data.summary" :catalog="data.catalog" />
<CollectionFilterBar
class="models-page__filters"
@@ -279,9 +231,9 @@ onBeforeUnmount(() => searchDebounce.cancel())
/>
</label>
<label class="collection-filter-field">
<span class="collection-filter-label">{{
t('models.filters.pricingStatusLabel')
}}</span>
<span class="collection-filter-label">
{{ t('models.filters.pricingStatusLabel') }}
</span>
<AppSelect
size="compact"
:label="t('models.filters.pricingStatusLabel')"
@@ -333,7 +285,7 @@ onBeforeUnmount(() => searchDebounce.cancel())
</EmptyState>
<template v-else>
<ModelTreeCollection :items="data.items" @edit="editScope" />
<ModelCollection :items="data.items" @edit="editBranch" />
<PaginationBar
:page="data.pagination.page"
:page-size="data.pagination.page_size"
@@ -345,6 +297,8 @@ onBeforeUnmount(() => searchDebounce.cancel())
@update:page-size="setPageSize"
/>
</template>
<p class="models-page__footnote">{{ t('models.context') }}</p>
</template>
<ModelPriceDrawer
@@ -372,66 +326,18 @@ onBeforeUnmount(() => searchDebounce.cancel())
font-size: var(--text-meta);
}
.models-page__overview {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
border-block: 1px solid var(--color-border-subtle);
padding-block: var(--space-3);
}
.models-page__summary {
display: grid;
min-width: 0;
grid-template-columns: repeat(5, minmax(96px, 1fr));
gap: var(--space-3);
margin: 0;
}
.models-page__summary div {
display: grid;
gap: 2px;
}
.models-page__summary dt,
.models-page__catalog-status span {
.models-page__footnote {
margin: calc(var(--space-4-5) * -0.45) 0 0;
color: var(--color-text-faint);
font-size: var(--text-label-xs);
}
.models-page__summary dd {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-meta);
font-weight: 650;
}
.models-page__catalog-status {
display: grid;
justify-items: end;
gap: 3px;
}
.models-page__catalog-status code {
color: var(--color-warning);
font-size: var(--text-label-xs);
}
.models-page :deep(.models-page__filters.collection-filter-bar) {
grid-template-columns: minmax(240px, 1fr) repeat(2, minmax(142px, 0.42fr));
padding-top: var(--space-1);
}
@media (max-width: 980px) {
.models-page__overview {
grid-template-columns: minmax(0, 1fr);
}
.models-page__catalog-status {
justify-items: start;
}
.models-page :deep(.models-page__filters.collection-filter-bar) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -442,10 +348,6 @@ onBeforeUnmount(() => searchDebounce.cancel())
}
@media (max-width: 680px) {
.models-page__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.models-page :deep(.page-header__actions) {
width: 100%;
}
+116
View File
@@ -0,0 +1,116 @@
import type { ModelPriceBranchDto, ModelRouteGroupDto } from '@/app/resources/models'
import type { ClientModelDto, ModelUpstreamDto } from '@/app/resources/models'
import type { ModelPriceSlotsDto } from '@/app/resources/model-prices'
/** 列表中直接展示的两个价格槽位,其余槽位只在展开详情中出现。 */
export type ModelSummaryField = Extract<keyof ModelPriceSlotsDto, 'input' | 'output'>
export type ModelPriceSummaryState = 'unavailable' | 'free' | 'single' | 'range'
export interface ModelPriceFieldSummary {
field: ModelSummaryField
state: ModelPriceSummaryState
/** 原始 decimal 字符串,保持后端精度;range 时为区间下界。 */
value: string | null
/** range 时的区间上界。 */
upper: string | null
}
export type ModelUpstreamShape = 'direct' | 'alias' | 'multi'
export interface ModelUpstreamSummary {
shape: ModelUpstreamShape
/** shape 为 alias 时的上游模型 ID。 */
modelID: string | null
count: number
}
export interface ModelScopePresentation {
upstream: ModelUpstreamDto
branch: ModelPriceBranchDto
}
export interface ClientModelPresentation {
model: ClientModelDto
scopes: ModelScopePresentation[]
pendingCount: number
status: 'configured' | 'pending'
upstream: ModelUpstreamSummary
routeGroups: ModelRouteGroupDto[]
input: ModelPriceFieldSummary
output: ModelPriceFieldSummary
/** 仅有一套价格时可以从列表直接编辑。 */
soleBranch: ModelPriceBranchDto | null
}
function summarizeUpstream(model: ClientModelDto): ModelUpstreamSummary {
const count = model.upstream_models.length
if (count !== 1) return { shape: 'multi', modelID: null, count }
const upstream = model.upstream_models[0]
if (upstream === undefined) return { shape: 'multi', modelID: null, count }
return upstream.alias_applied
? { shape: 'alias', modelID: upstream.model_id, count }
: { shape: 'direct', modelID: upstream.model_id, count }
}
function collectRouteGroups(scopes: ModelScopePresentation[]): ModelRouteGroupDto[] {
const groups = new Map<number, ModelRouteGroupDto>()
for (const { branch } of scopes) {
for (const group of branch.route_groups) {
if (!groups.has(group.id)) groups.set(group.id, group)
}
}
return [...groups.values()].sort((left, right) =>
left.name === right.name ? left.id - right.id : left.name.localeCompare(right.name),
)
}
function summarizeField(
scopes: ModelScopePresentation[],
field: ModelSummaryField,
): ModelPriceFieldSummary {
const priced = scopes
.map(({ branch }) => branch.price.prices[field])
.filter((value): value is string => value !== null)
if (priced.length === 0) {
return { field, state: 'unavailable', value: null, upper: null }
}
let lower = priced[0] as string
let upper = priced[0] as string
for (const value of priced) {
if (Number(value) < Number(lower)) lower = value
if (Number(value) > Number(upper)) upper = value
}
if (Number(lower) !== Number(upper)) {
return { field, state: 'range', value: lower, upper }
}
return { field, state: Number(lower) === 0 ? 'free' : 'single', value: lower, upper: null }
}
export function presentClientModel(model: ClientModelDto): ClientModelPresentation {
const scopes = model.upstream_models.flatMap((upstream) =>
upstream.prices.map((branch) => ({ upstream, branch })),
)
const pendingCount = scopes.filter(
({ branch }) => branch.price.pricing_status === 'pending',
).length
return {
model,
scopes,
pendingCount,
status: pendingCount > 0 ? 'pending' : 'configured',
upstream: summarizeUpstream(model),
routeGroups: collectRouteGroups(scopes),
input: summarizeField(scopes, 'input'),
output: summarizeField(scopes, 'output'),
soleBranch: scopes.length === 1 ? ((scopes[0] as ModelScopePresentation).branch ?? null) : null,
}
}
/** 价格范围是否影响了当前路由分组之外的分组。 */
export function hasWiderPriceImpact(branch: ModelPriceBranchDto): boolean {
if (branch.route_groups.length !== branch.affected_groups.length) return true
const routed = new Set(branch.route_groups.map(({ id }) => id))
return branch.affected_groups.some(({ id }) => !routed.has(id))
}
+43 -40
View File
@@ -23,13 +23,13 @@ export default {
upstreamModels: 'Upstream models',
prices: 'Price records',
pendingPrices: 'Pending prices',
unreferencedPrices: 'Unused prices',
unreferencedPrices: '{count} unused price records',
},
catalog: {
available: 'Models.dev catalog available',
stale: 'Sync failed; using the last successful catalog',
unavailable: 'Models.dev catalog unavailable',
lastSuccess: 'Last success',
lastSuccess: 'Last sync',
lastCheck: 'Last check',
},
drawerImpact: {
@@ -60,58 +60,61 @@ export default {
noResultsTitle: 'No matching models',
noResultsDescription: 'Adjust the search, Group status, or pricing status and try again',
},
tree: {
label: 'Client model catalog',
collection: {
label: 'Client model list',
scrollHint: 'Scroll horizontally for more columns',
columns: {
structure: 'Model and route structure',
status: 'Status',
model: 'Client model',
routing: 'Route Groups / protocols',
prices: 'Prices · USD / 1M',
facts: 'Source / actions',
status: 'Status',
actions: 'Actions',
},
clientModel: 'Client model',
direct: 'Passed through by the same name',
alias: 'Mapped to {model}',
multiUpstream: '{count} upstream models',
moreGroups: '+{count}',
scopeCount: '{count} price scopes',
pendingScopes: '{count} price scopes · {pending} pending',
priceRange: '{min} – {max}',
expand: 'Expand details for {model}',
collapse: 'Collapse details for {model}',
copy: 'Copy client model {model}',
copySucceeded: 'Client model copied',
copyFailed: 'Unable to copy the client model',
},
detail: {
upstreamModel: 'Upstream model',
directModel: 'Direct name',
prices: '{count} price scopes',
pricesWithPending: '{count} price scopes · {pending} pending',
direct: 'Same name',
aliasApplied: 'Alias',
scope: {
provider: 'Provider price',
group: 'Group-specific price',
},
groups: 'Route Groups',
routeGroups: 'Current route Groups',
globalImpact: 'Global impact · {count} Groups',
groupCount: '{count} Groups share this price',
routeGroups: 'Route Groups',
groupEnabled: 'Enabled',
groupDisabled: 'Disabled',
customProvider: 'Custom provider',
copy: 'Copy client model {model}',
copySucceeded: 'Client model copied',
copyFailed: 'Unable to copy the client model',
globalImpact: 'This price affects {count} Groups in total',
catalogReference: {
actual_provider: 'Models.dev actual provider · {provider}',
actual_provider: 'Models.dev · {provider}',
reference_provider: 'Models.dev reference provider · {provider}',
},
catalogUnavailable: 'No Models.dev model information',
metadata: {
family: 'Family {value}',
status: 'Status {value}',
context: 'Context {value}',
inputLimit: 'Max input {value}',
outputLimit: 'Max output {value}',
inputModalities: 'Input {value}',
outputModalities: 'Output {value}',
releaseDate: 'Released {value}',
lastUpdated: 'Updated {value}',
knowledge: 'Knowledge cutoff {value}',
openWeights: 'Open weights',
capabilities: {
attachment: 'Attachments',
reasoning: 'Reasoning',
tool_call: 'Tool calls',
structured_output: 'Structured output',
temperature: 'Temperature',
},
specs: {
context: 'Context',
maxOutput: 'Max output',
modalities: 'Modalities',
knowledge: 'Knowledge cutoff',
released: 'Released',
family: 'Family',
},
capabilities: {
attachment: 'Attachments',
reasoning: 'Reasoning',
tool_call: 'Tool calls',
structured_output: 'Structured output',
temperature: 'Temperature',
},
openWeights: 'Open weights',
},
},
} as const
+43 -40
View File
@@ -23,13 +23,13 @@ export default {
upstreamModels: '上流モデル',
prices: '価格レコード',
pendingPrices: '価格待ち',
unreferencedPrices: '未使用価格',
unreferencedPrices: '未使用の価格レコード {count} 件',
},
catalog: {
available: 'Models.dev カタログ利用可能',
stale: '同期失敗・前回成功したカタログを使用中',
unavailable: 'Models.dev カタログ利用不可',
lastSuccess: '最終成功',
lastSuccess: '最終同期',
lastCheck: '最終確認',
},
drawerImpact: {
@@ -60,58 +60,61 @@ export default {
noResultsTitle: '条件に一致するモデルがありません',
noResultsDescription: '検索、Group の状態、価格状態を変更して再度お試しください',
},
tree: {
label: 'クライアントモデルカタログ',
collection: {
label: 'クライアントモデル一覧',
scrollHint: '横スクロールで他の列を表示できます',
columns: {
structure: 'モデルとルート構造',
status: '状態',
model: 'クライアントモデル',
routing: 'ルート Group / プロトコル',
prices: '価格 · USD / 1M',
facts: '参照元 / 操作',
status: '状態',
actions: '操作',
},
clientModel: 'クライアントモデル',
direct: '同名で直接転送',
alias: '{model} にマッピング',
multiUpstream: '上流モデル {count} 件',
moreGroups: '+{count}',
scopeCount: '価格範囲 {count} 件',
pendingScopes: '価格範囲 {count} 件 · {pending} 件が価格待ち',
priceRange: '{min} – {max}',
expand: '{model} の詳細を開く',
collapse: '{model} の詳細を閉じる',
copy: 'クライアントモデル {model} をコピー',
copySucceeded: 'クライアントモデルをコピーしました',
copyFailed: 'クライアントモデルをコピーできません',
},
detail: {
upstreamModel: '上流モデル',
directModel: '同名で直接転送',
prices: '{count} 件の価格範囲',
pricesWithPending: '{count} 件の価格範囲 · {pending} 件が価格待ち',
direct: '同名',
aliasApplied: 'エイリアス',
scope: {
provider: 'プロバイダー価格',
group: 'Group 固有価格',
},
groups: 'ルート Group',
routeGroups: '現在のルート Group',
globalImpact: '全体影響 · {count} Group',
groupCount: '{count} Group がこの価格を共有',
routeGroups: 'ルート Group',
groupEnabled: '有効',
groupDisabled: '無効',
customProvider: 'カスタムプロバイダー',
copy: 'クライアントモデル {model} をコピー',
copySucceeded: 'クライアントモデルをコピーしました',
copyFailed: 'クライアントモデルをコピーできません',
globalImpact: 'この価格は合計 {count} Group に影響します',
catalogReference: {
actual_provider: 'Models.dev 実プロバイダー · {provider}',
actual_provider: 'Models.dev · {provider}',
reference_provider: 'Models.dev 参照プロバイダー · {provider}',
},
catalogUnavailable: 'Models.dev モデル情報はありません',
metadata: {
family: 'ファミリー {value}',
status: '状態 {value}',
context: 'コンテキスト {value}',
inputLimit: '最大入力 {value}',
outputLimit: '最大出力 {value}',
inputModalities: '入力 {value}',
outputModalities: '出力 {value}',
releaseDate: 'リリース {value}',
lastUpdated: '更新 {value}',
knowledge: '知識の期限 {value}',
openWeights: 'オープンウェイト',
capabilities: {
attachment: '添付',
reasoning: '推論',
tool_call: 'ツール呼び出し',
structured_output: '構造化出力',
temperature: 'Temperature',
},
specs: {
context: 'コンテキスト',
maxOutput: '最大出力',
modalities: 'モダリティ',
knowledge: '知識の期限',
released: 'リリース',
family: 'ファミリー',
},
capabilities: {
attachment: '添付',
reasoning: '推論',
tool_call: 'ツール呼び出し',
structured_output: '構造化出力',
temperature: 'Temperature',
},
openWeights: 'オープンウェイト',
},
},
} as const
+44 -41
View File
@@ -21,13 +21,13 @@ export default {
upstreamModels: '上游模型',
prices: '价格记录',
pendingPrices: '待定价',
unreferencedPrices: '未使用价格',
unreferencedPrices: '{count} 条未使用价格记录',
},
catalog: {
available: 'Models.dev 目录可用',
stale: '同步失败,使用上次成功目录',
unavailable: 'Models.dev 目录不可用',
lastSuccess: '最近成功',
lastSuccess: '最近同步',
lastCheck: '最近检查',
},
drawerImpact: {
@@ -58,58 +58,61 @@ export default {
noResultsTitle: '没有符合条件的模型',
noResultsDescription: '调整搜索、分组状态或价格状态后再试',
},
tree: {
label: '客户端模型目录',
collection: {
label: '客户端模型列表',
scrollHint: '可横向滚动查看更多列',
columns: {
structure: '模型与路由结构',
status: '状态',
model: '客户端模型',
routing: '路由分组 / 协议',
prices: '价格 · USD / 1M',
facts: '来源 / 操作',
status: '状态',
actions: '操作',
},
clientModel: '客户端模型',
direct: '同名直传上游',
alias: '映射到 {model}',
multiUpstream: '{count} 个上游模型',
moreGroups: '+{count}',
scopeCount: '{count} 套价格',
pendingScopes: '{count} 套价格 · {pending} 套待定',
priceRange: '{min} – {max}',
expand: '展开 {model} 的模型详情',
collapse: '收起 {model} 的模型详情',
copy: '复制客户端模型 {model}',
copySucceeded: '客户端模型已复制',
copyFailed: '无法复制客户端模型',
},
detail: {
upstreamModel: '上游模型',
directModel: '同名直传',
prices: '{count} 套价格',
pricesWithPending: '{count} 套价格 · {pending} 套待定价',
direct: '同名直传',
aliasApplied: '别名映射',
scope: {
provider: '渠道价格',
group: '分组独立价格',
},
groups: '路由分组',
routeGroups: '当前路径分组',
globalImpact: '全局影响 · {count} 个分组',
groupCount: '{count} 个分组共享此价格',
routeGroups: '路由分组',
groupEnabled: '启用',
groupDisabled: '停用',
customProvider: '自定义渠道',
copy: '复制客户端模型 {model}',
copySucceeded: '客户端模型已复制',
copyFailed: '无法复制客户端模型',
groupDisabled: '已停用',
globalImpact: '此价格共影响 {count} 个分组',
catalogReference: {
actual_provider: 'Models.dev 实际渠道 · {provider}',
actual_provider: 'Models.dev · {provider}',
reference_provider: 'Models.dev 参考渠道 · {provider}',
},
catalogUnavailable: '暂无 Models.dev 模型信息',
metadata: {
family: '系列 {value}',
status: '状态 {value}',
context: '上下文 {value}',
inputLimit: '最大输入 {value}',
outputLimit: '最大输出 {value}',
inputModalities: '输入 {value}',
outputModalities: '输出 {value}',
releaseDate: '发布 {value}',
lastUpdated: '更新 {value}',
knowledge: '知识截止 {value}',
openWeights: '开放权重',
capabilities: {
attachment: '附件',
reasoning: '推理',
tool_call: '工具调用',
structured_output: '结构化输出',
temperature: '温度参数',
},
specs: {
context: '上下文',
maxOutput: '最大输出',
modalities: '模态',
knowledge: '知识截止',
released: '发布',
family: '系列',
},
capabilities: {
attachment: '附件',
reasoning: '推理',
tool_call: '工具调用',
structured_output: '结构化输出',
temperature: '温度参数',
},
openWeights: '开放权重',
},
},
} as const