Files
sub2api/frontend/src/views/admin/GroupsView.vue
T
IanShaw027 b830bc14d6 fix: 长上下文默认保持开启,并与 OpenAI 账号开关取交集
迁移默认 false 会让存量分组静默丢掉 ≥200k 阶梯与渠道多档价。
OpenAI 路径传入 Group 后,账号级 openai_long_context_billing_enabled 被顶掉。

- 列默认 TRUE,并对存量行回填
- 分组价卡不再手填 token 区间,勾选走官方/预设阶梯
- 分组开关与账号开关 AND;价卡 JSON 损坏时打 warn
2026-08-13 08:38:10 +08:00

6808 lines
259 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<AppLayout>
<TablePageLayout>
<template #filters>
<div
class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.groups.searchGroups')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<Select
v-model="filters.platform"
:options="platformFilterOptions"
:placeholder="t('admin.groups.allPlatforms')"
class="w-44"
@change="loadGroups"
/>
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.groups.allStatus')"
class="w-40"
@change="loadGroups"
/>
<Select
v-model="filters.is_exclusive"
:options="exclusiveOptions"
:placeholder="t('admin.groups.allGroups')"
class="w-44"
@change="loadGroups"
/>
</div>
<!-- Right: actions -->
<div
class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@click="loadGroups"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon
name="refresh"
size="md"
:class="loading ? 'animate-spin' : ''"
/>
</button>
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary"
:title="t('admin.groups.columnSettings')"
>
<Icon name="grid" size="md" class="mr-2" />
<span class="hidden md:inline">{{
t("admin.groups.columnSettings")
}}</span>
</button>
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<Icon
v-if="isColumnVisible(col.key)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</button>
</div>
</div>
<button
@click="openSortModal"
class="btn btn-secondary"
:title="t('admin.groups.sortOrder')"
>
<Icon name="arrowsUpDown" size="md" class="mr-2" />
{{ t("admin.groups.sortOrder") }}
</button>
<button
@click="openCreateModal"
class="btn btn-primary"
data-tour="groups-create-btn"
>
<Icon name="plus" size="md" class="mr-2" />
{{ t("admin.groups.createGroup") }}
</button>
</div>
</div>
</template>
<template #table>
<DataTable
:columns="columns"
:data="groups"
:loading="loading"
:server-side-sort="true"
default-sort-key="sort_order"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{
value
}}</span>
</template>
<template #cell-id="{ value }">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400"
>#{{ value }}</span
>
</template>
<template #cell-platform="{ value }">
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
value === 'anthropic'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: value === 'openai'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: value === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: value === 'grok'
? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
<PlatformIcon :platform="value" size="xs" />
{{ t("admin.groups.platforms." + value) }}
</span>
</template>
<template #cell-billing_type="{ row }">
<div class="space-y-1">
<!-- Type Badge -->
<span
:class="[
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
row.subscription_type === 'subscription'
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
]"
>
{{
row.subscription_type === "subscription"
? t("admin.groups.subscription.subscription")
: t("admin.groups.subscription.standard")
}}
</span>
<!-- Subscription Limits - compact single line -->
<div
v-if="row.subscription_type === 'subscription'"
class="space-y-0.5 text-xs text-gray-500 dark:text-gray-400"
>
<div
v-if="
row.daily_limit_usd ||
row.weekly_limit_usd ||
row.monthly_limit_usd
"
class="flex flex-wrap items-center gap-x-1 gap-y-0.5"
>
<span v-if="row.daily_limit_usd" class="whitespace-nowrap">
<span
v-if="usageLoading"
class="font-medium text-gray-400 dark:text-gray-500"
>—</span
>
<span
v-else
:class="
getQuotaUsageClass(
usageMap.get(row.id)?.today_cost ?? 0,
row.daily_limit_usd
)
"
>{{
formatUsd(usageMap.get(row.id)?.today_cost ?? 0)
}}</span
>
<span class="text-gray-400 dark:text-gray-500">
/ {{ formatUsd(row.daily_limit_usd) }}/{{
t("admin.groups.limitDay")
}}</span
>
</span>
<span
v-if="
row.daily_limit_usd &&
(row.weekly_limit_usd || row.monthly_limit_usd)
"
class="mx-1 text-gray-300 dark:text-gray-600"
>·</span
>
<span v-if="row.weekly_limit_usd" class="whitespace-nowrap"
>{{ formatUsd(row.weekly_limit_usd) }}/{{
t("admin.groups.limitWeek")
}}</span
>
<span
v-if="row.weekly_limit_usd && row.monthly_limit_usd"
class="mx-1 text-gray-300 dark:text-gray-600"
>·</span
>
<span v-if="row.monthly_limit_usd" class="whitespace-nowrap"
>{{ formatUsd(row.monthly_limit_usd) }}/{{
t("admin.groups.limitMonth")
}}</span
>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">{{
t("admin.groups.subscription.noLimit")
}}</span>
<div class="text-gray-400 dark:text-gray-500">
{{ t("admin.groups.usageTotal") }}
<span class="ml-1 font-medium text-gray-600 dark:text-gray-300"
>{{
usageLoading
? "—"
: formatUsd(usageMap.get(row.id)?.total_cost ?? 0)
}}</span
>
</div>
</div>
</div>
</template>
<template #cell-rate_multiplier="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300"
>{{ value }}x</span
>
</template>
<template #cell-is_exclusive="{ value }">
<span :class="['badge', value ? 'badge-primary' : 'badge-gray']">
{{
value ? t("admin.groups.exclusive") : t("admin.groups.public")
}}
</span>
</template>
<template #cell-account_count="{ row }">
<div class="space-y-0.5 text-xs">
<div>
<span class="text-gray-500 dark:text-gray-400">{{
t("admin.groups.accountsAvailable")
}}</span>
<span
class="ml-1 font-medium text-emerald-600 dark:text-emerald-400"
>{{ row.active_account_count || 0 }}</span
>
<span
class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>{{ t("admin.groups.accountsUnit") }}</span
>
</div>
<div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{
t("admin.groups.accountsRateLimited")
}}</span>
<span
class="ml-1 font-medium text-amber-600 dark:text-amber-400"
>{{ row.rate_limited_account_count }}</span
>
<span
class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>{{ t("admin.groups.accountsUnit") }}</span
>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{{
t("admin.groups.accountsTotal")
}}</span>
<span
class="ml-1 font-medium text-gray-700 dark:text-gray-300"
>{{ row.account_count || 0 }}</span
>
<span
class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>{{ t("admin.groups.accountsUnit") }}</span
>
</div>
</div>
</template>
<template #cell-capacity="{ row }">
<GroupCapacityBadge
v-if="capacityMap.get(row.id)"
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
:rpm-max="capacityMap.get(row.id)!.rpmMax"
/>
<span v-else class="text-xs text-gray-400">—</span>
</template>
<template #cell-usage="{ row }">
<div v-if="usageLoading" class="text-xs text-gray-400">—</div>
<div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{
t("admin.groups.usageToday")
}}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300"
>${{
formatCost(usageMap.get(row.id)?.today_cost ?? 0)
}}</span
>
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="text-gray-400 dark:text-gray-500">{{
t("admin.groups.usageTotal")
}}</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300"
>${{
formatCost(usageMap.get(row.id)?.total_cost ?? 0)
}}</span
>
</div>
</div>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger',
]"
>
{{ t("admin.accounts.status." + value) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="edit" size="sm" />
<span class="text-xs">{{ t("common.edit") }}</span>
</button>
<button
data-testid="group-duplicate"
:title="
duplicatingGroupIds.has(row.id)
? t('admin.groups.duplicating')
: t('admin.groups.duplicate')
"
:disabled="duplicatingGroupIds.has(row.id)"
@click="handleDuplicate(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<Icon name="copy" size="sm" />
<span class="text-xs">
{{
duplicatingGroupIds.has(row.id)
? t("admin.groups.duplicating")
: t("admin.groups.duplicate")
}}
</span>
</button>
<button
v-if="row.platform === 'composite'"
@click="handleCompositeRoutes(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-cyan-600 dark:hover:bg-dark-700 dark:hover:text-cyan-400"
>
<Icon name="swap" size="sm" />
<span class="text-xs">{{
t("admin.groups.compositeRoutes.action")
}}</span>
</button>
<button
@click="handleRateMultipliers(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
>
<Icon name="dollar" size="sm" />
<span class="text-xs">{{
t("admin.groups.rateMultipliers")
}}</span>
</button>
<button
@click="handleRPMOverrides(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-orange-600 dark:hover:bg-dark-700 dark:hover:text-orange-400"
>
<Icon name="bolt" size="sm" />
<span class="text-xs">{{
t("admin.groups.rpmOverrides")
}}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon name="trash" size="sm" />
<span class="text-xs">{{ t("common.delete") }}</span>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.groups.noGroupsYet')"
:description="t('admin.groups.createFirstGroup')"
:action-text="t('admin.groups.createGroup')"
@action="openCreateModal"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create Group Modal -->
<BaseDialog
:show="showCreateModal"
:title="t('admin.groups.createGroup')"
width="normal"
@close="closeCreateModal"
>
<form
id="create-group-form"
@submit.prevent="handleCreateGroup"
class="space-y-5"
>
<div>
<label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input
v-model="createForm.name"
type="text"
required
class="input"
:placeholder="t('admin.groups.enterGroupName')"
data-tour="group-form-name"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.description")
}}</label>
<textarea
v-model="createForm.description"
rows="3"
class="input"
:placeholder="t('admin.groups.optionalDescription')"
></textarea>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.platform")
}}</label>
<Select
v-model="createForm.platform"
:options="platformOptions"
data-tour="group-form-platform"
@change="createForm.copy_accounts_from_group_ids = []"
/>
<p class="input-hint">{{ t("admin.groups.platformHint") }}</p>
</div>
<!-- 从分组复制账号 -->
<div v-if="copyAccountsGroupOptions.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.copyAccounts.title") }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.copyAccounts.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div
v-if="createForm.copy_accounts_from_group_ids.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span
v-for="groupId in createForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
copyAccountsGroupOptions.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button
type="button"
@click="
createForm.copy_accounts_from_group_ids =
createForm.copy_accounts_from_group_ids.filter(
(id) => id !== groupId,
)
"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="
(e) => {
const val = Number((e.target as HTMLSelectElement).value);
if (
val &&
!createForm.copy_accounts_from_group_ids.includes(val)
) {
createForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
}
"
>
<option value="">
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option
v-for="opt in copyAccountsGroupOptions"
:key="opt.value"
:value="opt.value"
:disabled="
createForm.copy_accounts_from_group_ids.includes(opt.value)
"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t("admin.groups.copyAccounts.hint") }}</p>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.rateMultiplier")
}}</label>
<input
v-model.number="createForm.rate_multiplier"
type="number"
step="0.001"
min="0.001"
required
class="input"
data-tour="group-form-multiplier"
/>
<p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p>
</div>
<div>
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
<input
v-model.number="createForm.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="supportsReasoningEffortPolicyPlatform(createForm.platform)"
ref="createReasoningEffortPolicyRef"
id-prefix="create-group-reasoning"
:platform="createForm.platform"
v-model:max-effort="createForm.max_reasoning_effort"
v-model:mappings="createForm.reasoning_effort_mappings"
/>
<div
v-if="createForm.subscription_type !== 'subscription'"
data-tour="group-form-exclusive"
>
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.form.exclusive") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="mb-2 text-xs font-medium">
{{ t("admin.groups.exclusiveTooltip.title") }}
</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.exclusiveTooltip.description") }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span
class="inline-flex items-center gap-1 text-primary-400"
><Icon name="lightbulb" size="xs" />
{{ t("admin.groups.exclusiveTooltip.example") }}</span
>
{{ t("admin.groups.exclusiveTooltip.exampleContent") }}
</p>
</div>
<!-- Arrow -->
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
createForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span>
</div>
</div>
<!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4">
<div>
<label class="input-label">{{
t("admin.groups.subscription.type")
}}</label>
<Select
v-model="createForm.subscription_type"
:options="subscriptionTypeOptions"
/>
<p class="input-hint">
{{ t("admin.groups.subscription.typeHint") }}
</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
<div
v-if="createForm.subscription_type === 'subscription'"
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label class="input-label">{{
t("admin.groups.subscription.dailyLimit")
}}</label>
<input
v-model.number="createForm.daily_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.subscription.weeklyLimit")
}}</label>
<input
v-model.number="createForm.weekly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.subscription.monthlyLimit")
}}</label>
<input
v-model.number="createForm.monthly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
</div>
</div>
<div class="border-t pt-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.modelsList.title") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.modelsList.hint") }}
</p>
</div>
<button
type="button"
@click="createModelsListState.enabled = !createModelsListState.enabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors',
createModelsListState.enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createModelsListState.enabled ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
</div>
<div
v-if="createModelsListState.enabled"
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50/50 dark:border-dark-600 dark:bg-dark-800/40"
>
<div
v-if="!createModelsListLoading && createModelsListState.items.length > 0"
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
>
<span class="text-gray-500 dark:text-gray-400">
{{
t("admin.groups.modelsList.selectedSummary", {
selected: createModelsListSelectedCount,
total: createModelsListState.items.length,
})
}}
</span>
<div class="flex items-center gap-1.5">
<button
type="button"
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
@click="selectAllModelsListItems(createModelsListState)"
>
{{ t("admin.groups.modelsList.selectAll") }}
</button>
<button
type="button"
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
@click="invertModelsListSelection(createModelsListState)"
>
{{ t("admin.groups.modelsList.invertSelection") }}
</button>
</div>
</div>
<div
class="max-h-64 space-y-2 overflow-y-auto p-2"
>
<p v-if="createModelsListLoading" class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.modelsList.loading") }}
</p>
<p
v-else-if="createModelsListState.items.length === 0"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.modelsList.empty") }}
</p>
<div
v-for="(item, index) in createModelsListState.items"
:key="item.id"
class="flex items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2 dark:border-dark-600 dark:bg-dark-800"
>
<input
v-model="item.selected"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
{{ item.id }}
</span>
<button
type="button"
:disabled="index === 0"
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
@click="moveCreateModelsListItem(index, index - 1)"
>
<Icon name="arrowUp" size="sm" />
</button>
<button
type="button"
:disabled="index === createModelsListState.items.length - 1"
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
@click="moveCreateModelsListItem(index, index + 1)"
>
<Icon name="arrowDown" size="sm" />
</button>
</div>
</div>
</div>
</div>
<!-- 图片生成计费配置 -->
<div
v-if="supportsImagePricingPlatform(createForm.platform)"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t(imagePricingI18nKey(createForm.platform, "title")) }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t(imagePricingI18nKey(createForm.platform, "description")) }}
</p>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.allow_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(imagePricingI18nKey(createForm.platform, "allowImageGeneration")) }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.image_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(imagePricingI18nKey(createForm.platform, "independentMultiplier")) }}
</label>
</div>
<div
v-if="createForm.image_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t(imagePricingI18nKey(createForm.platform, "imageMultiplier"))
}}</label>
<input
v-model.number="createForm.image_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
<input
v-model.number="createForm.image_price_1k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_1k')"
/>
</div>
<div>
<label class="input-label">2K ($)</label>
<input
v-model.number="createForm.image_price_2k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_2k')"
/>
</div>
<div>
<label class="input-label">4K ($)</label>
<input
v-model.number="createForm.image_price_4k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(createForm.platform, 'image_price_4k')"
/>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(imagePricingI18nKey(createForm.platform, "modeHint")) }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t(imagePricingI18nKey(createForm.platform, "finalPricePreview")) }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in createImageFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
<div v-if="createForm.platform === 'gemini' && createForm.allow_image_generation" class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<label
class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
<input
v-model="createForm.allow_batch_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowBatchImageGeneration") }}
</label>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.batchSectionHint") }}
</p>
<div
v-if="createForm.allow_batch_image_generation"
class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2"
>
<div>
<label class="input-label">{{
t("admin.groups.imagePricing.batchDiscountMultiplier")
}}</label>
<input
v-model.number="createForm.batch_image_discount_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="0.5"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.imagePricing.batchHoldMultiplier")
}}</label>
<input
v-model.number="createForm.batch_image_hold_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="0.6"
/>
</div>
</div>
</div>
<p
v-else-if="createForm.platform !== 'gemini'"
class="mt-4 border-t border-dashed border-gray-200 pt-4 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }}
</p>
</div>
<!-- 视频生成计费配置(仅 Grok 平台) -->
<div
v-if="supportsVideoPricingPlatform(createForm.platform)"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t(videoPricingI18nKey("title")) }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t(videoPricingI18nKey("description")) }}
</p>
<div class="mb-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.video_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(videoPricingI18nKey("independentMultiplier")) }}
</label>
</div>
<div
v-if="createForm.video_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t(videoPricingI18nKey("videoMultiplier"))
}}</label>
<input
v-model.number="createForm.video_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="createForm.video_price_480p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_480p')"
/>
</div>
<div>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="createForm.video_price_720p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_720p')"
/>
</div>
<div>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="createForm.video_price_1080p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(createForm.platform, 'video_price_1080p')"
/>
</div>
</div>
<div
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
data-testid="create-grok-video-model-prices"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
</p>
<div class="mt-3 space-y-3">
<div
v-for="family in videoModelPriceFamilyRows(createForm.video_model_prices)"
:key="family.key"
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
>
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ family.label }}
</div>
<label
v-for="resolution in grokVideoPriceResolutions"
:key="resolution.key"
class="block"
>
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
{{ resolution.label }} ($/s)
</span>
<input
v-model.number="createForm.video_model_prices[family.key][resolution.key]"
type="number"
step="0.001"
min="0"
class="input"
:data-testid="`create-grok-video-price-${family.key}-${resolution.key}`"
/>
</label>
</div>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(videoPricingI18nKey("modeHint")) }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t(videoPricingI18nKey("finalPricePreview")) }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in createVideoFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
</div>
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
<div v-if="createForm.subscription_type === 'subscription'" class="border-t pt-4">
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.peak_rate_enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>{{ t("admin.groups.peakRate.enable") }}</span>
</label>
</div>
<div
v-if="createForm.peak_rate_enabled"
class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
<input
v-model="createForm.peak_start"
type="time"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakEnd") }}</label>
<input
v-model="createForm.peak_end"
type="time"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakMultiplier") }}</label>
<input
v-model.number="createForm.peak_rate_multiplier"
type="number"
step="0.001"
min="0"
class="input"
placeholder="1"
:title="t('admin.groups.peakRate.multiplierHint')"
/>
</div>
</div>
</div>
<!-- 分组利润控制(五个平台 token 请求) -->
<div v-if="isProfitControlPlatform(createForm.platform)" class="border-t pt-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="createForm.profit_control_enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>{{ t("admin.groups.profitControl.enable") }}</span>
</label>
<p class="mb-3 mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
createForm.profit_control_enabled
? t("admin.groups.profitControl.enabledHint")
: t("admin.groups.profitControl.disabledHint")
}}
</p>
<div
v-if="createForm.profit_control_enabled"
class="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<div>
<label class="input-label">{{ t("admin.groups.profitControl.minMargin") }}</label>
<input
v-model.number="createForm.profit_min_margin_percent"
type="number"
step="0.1"
min="0"
max="99.99"
class="input"
placeholder="0"
:title="t('admin.groups.profitControl.minMarginHint')"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.profitControl.safetyBuffer") }}</label>
<input
v-model.number="createForm.profit_safety_buffer_percent"
type="number"
step="0.1"
min="0"
max="99.99"
class="input"
placeholder="0"
:title="t('admin.groups.profitControl.safetyBufferHint')"
/>
</div>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.supportedScopes.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.supportedScopes.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('claude')"
@change="toggleCreateScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.claude")
}}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="
createForm.supported_model_scopes.includes('gemini_text')
"
@change="toggleCreateScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiText")
}}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="
createForm.supported_model_scopes.includes('gemini_image')
"
@change="toggleCreateScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiImage")
}}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div>
<!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.mcpXml.title") }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.mcpXml.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.mcp_xml_inject = !createForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
createForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span>
</div>
</div>
<!-- Claude Code 客户端限制(仅 anthropic 平台) -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.claudeCode.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.claudeCode.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="
createForm.claude_code_only = !createForm.claude_code_only
"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.claude_code_only
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
createForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span>
</div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div v-if="createForm.claude_code_only" class="mt-3">
<label class="input-label">{{
t("admin.groups.claudeCode.fallbackGroup")
}}</label>
<Select
v-model="createForm.fallback_group_id"
:options="fallbackGroupOptions"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div>
</div>
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.webSearchPricing.title") }}
</h4>
<div>
<label class="input-label">{{
t("admin.groups.webSearchPricing.pricePerCall")
}}</label>
<input
v-model.number="createForm.web_search_price_per_call"
type="number"
step="0.001"
min="0"
placeholder="0.01"
class="input"
/>
<p class="input-hint">
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
</p>
<div
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{
t("admin.groups.webSearchPricing.finalPricePreview", {
price: createWebSearchFinalPricePreview,
})
}}
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(createForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="createForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in createForm.model_pricing" :key="index" :entry="entry" :platform="createForm.platform" hide-token-intervals @update="createForm.model_pricing[index] = $event" @remove="createForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.explicitPricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.explicitPricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
<input
v-model.number="createForm.search_price_per_1k"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
data-testid="create-search-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="createForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="createForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="createForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiLive.title") }}
</h4>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiLive.allow")
}}</label>
<button
type="button"
@click="toggleLive('create')"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.allow_live
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="createForm.allow_live ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiLive.hint") }}
</p>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiMessages.title") }}
</h4>
<!-- 允许 Messages 调度开关 -->
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiMessages.allowDispatch")
}}</label>
<button
type="button"
@click="
createForm.allow_messages_dispatch =
!createForm.allow_messages_dispatch
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
createForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiMessages.allowDispatchHint") }}
</p>
<div v-if="createForm.allow_messages_dispatch" class="mt-3">
<div
class="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
>
<div
class="border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
<label
class="text-sm font-medium text-gray-900 dark:text-white"
>{{
t("admin.groups.openaiMessages.familyMappingTitle")
}}</label
>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.openaiMessages.familyMappingHint") }}
</p>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.opusModel")
}}</label>
<input
v-model="createForm.opus_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.opusModelPlaceholder')
"
class="input"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.sonnetModel")
}}</label>
<input
v-model="createForm.sonnet_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.sonnetModelPlaceholder')
"
class="input"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.haikuModel")
}}</label>
<input
v-model="createForm.haiku_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.haikuModelPlaceholder')
"
class="input"
/>
</div>
</div>
</div>
</div>
<div
class="mt-5 relative overflow-hidden rounded-xl border border-primary-200 bg-white shadow-sm dark:border-primary-900/50 dark:bg-dark-800"
>
<div
class="border-b border-primary-100 bg-primary-50/80 px-4 py-3 dark:border-primary-900/40 dark:bg-primary-900/20"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-primary-500"></div>
<label
class="text-sm font-medium text-primary-900 dark:text-primary-100"
>{{
t("admin.groups.openaiMessages.exactMappingTitle")
}}</label
>
</div>
<p
class="mt-1 text-xs text-primary-600/90 dark:text-primary-400/90"
>
{{ t("admin.groups.openaiMessages.exactMappingHint") }}
</p>
</div>
</div>
</div>
<div class="p-4 bg-gray-50/30 dark:bg-dark-800/30">
<div
v-if="createForm.exact_model_mappings.length === 0"
class="flex items-center justify-between gap-3 rounded-xl border-2 border-dashed border-primary-200 bg-white px-5 py-4 text-sm text-primary-700 transition-colors hover:border-primary-300 dark:border-primary-900/40 dark:bg-dark-800 dark:text-primary-300 dark:hover:border-primary-800"
>
<span>{{
t("admin.groups.openaiMessages.noExactMappings")
}}</span>
<button
type="button"
@click="addCreateMessagesDispatchMapping"
class="flex items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
<div v-else class="space-y-3">
<div
v-for="row in createForm.exact_model_mappings"
:key="getCreateMessagesDispatchRowKey(row)"
class="group relative rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-primary-300 hover:shadow-md dark:border-dark-600 dark:bg-dark-700 dark:hover:border-primary-700"
>
<div class="flex items-center gap-4">
<div
class="grid flex-1 gap-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-start"
>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.claudeModel")
}}</label>
<input
v-model="row.claude_model"
type="text"
:placeholder="
t(
'admin.groups.openaiMessages.claudeModelPlaceholder',
)
"
class="input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
<div
class="hidden md:flex md:justify-center md:pt-7 text-primary-300 dark:text-primary-700"
>
<Icon
name="arrowRight"
size="sm"
class="transition-transform group-hover:translate-x-1"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.targetModel")
}}</label>
<input
v-model="row.target_model"
type="text"
:placeholder="
t(
'admin.groups.openaiMessages.targetModelPlaceholder',
)
"
class="input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
</div>
<button
type="button"
@click="removeCreateMessagesDispatchMapping(row)"
class="mt-6 flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="
t('admin.groups.openaiMessages.removeExactMapping')
"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
<button
type="button"
@click="addCreateMessagesDispatchMapping"
class="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-gray-300 bg-white py-3 text-sm font-medium text-gray-500 transition-all hover:border-primary-300 hover:bg-primary-50/50 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-primary-800 dark:hover:bg-primary-900/20 dark:hover:text-primary-400"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) -->
<div
v-if="
['openai', 'antigravity', 'anthropic', 'gemini'].includes(
createForm.platform,
)
"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.accountFilters.title") }}
</h4>
<!-- require_oauth_only toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
createForm.require_oauth_only
? t("admin.groups.accountFilters.oauthOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
<button
type="button"
@click="
createForm.require_oauth_only = !createForm.require_oauth_only
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
createForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<!-- require_privacy_set toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
createForm.require_privacy_set
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
<button
type="button"
@click="
createForm.require_privacy_set = !createForm.require_privacy_set
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
createForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
createForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
</div>
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if="
['anthropic', 'antigravity'].includes(createForm.platform) &&
createForm.subscription_type !== 'subscription'
"
class="border-t pt-4"
>
<label class="input-label">{{
t("admin.groups.invalidRequestFallback.title")
}}</label>
<Select
v-model="createForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptions"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div>
<!-- 模型路由配置(仅 anthropic 平台) -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.modelRouting.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.modelRouting.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 启用开关 -->
<div class="flex items-center gap-3 mb-3">
<button
type="button"
@click="
createForm.model_routing_enabled =
!createForm.model_routing_enabled
"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
createForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span>
</div>
<p
v-if="!createForm.model_routing_enabled"
class="text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t("admin.groups.modelRouting.disabledHint") }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.modelRouting.noRulesHint") }}
</p>
<!-- 路由规则列表(仅在启用时显示) -->
<div v-if="createForm.model_routing_enabled" class="space-y-3">
<div
v-for="rule in createModelRoutingRules"
:key="getCreateRuleRenderKey(rule)"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="flex items-start gap-3">
<div class="flex-1 space-y-2">
<div>
<label class="input-label text-xs">{{
t("admin.groups.modelRouting.modelPattern")
}}</label>
<input
v-model="rule.pattern"
type="text"
class="input text-sm"
:placeholder="
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/>
</div>
<div>
<label class="input-label text-xs">{{
t("admin.groups.modelRouting.accounts")
}}</label>
<!-- 已选账号标签 -->
<div
v-if="rule.accounts.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span
v-for="account in rule.accounts"
:key="account.id"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ account.name }}
<button
type="button"
@click="removeSelectedAccount(rule, account.id)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 账号搜索输入框 -->
<div class="relative account-search-container">
<input
v-model="
accountSearchKeyword[getCreateRuleSearchKey(rule)]
"
type="text"
class="input text-sm"
:placeholder="
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@input="searchAccountsByRule(rule)"
@focus="onAccountSearchFocus(rule)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if="
showAccountDropdown[getCreateRuleSearchKey(rule)] &&
accountSearchResults[getCreateRuleSearchKey(rule)]
?.length > 0
"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in accountSearchResults[
getCreateRuleSearchKey(rule)
]"
:key="account.id"
type="button"
@click="selectAccount(rule, account)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{
'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled="
rule.accounts.some((a) => a.id === account.id)
"
>
<span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400"
>#{{ account.id }}</span
>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div>
</div>
<button
type="button"
@click="removeCreateRoutingRule(rule)"
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
:title="t('admin.groups.modelRouting.removeRule')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- 添加规则按钮(仅在启用时显示) -->
<button
v-if="createForm.model_routing_enabled"
type="button"
@click="addCreateRoutingRule"
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.modelRouting.addRule") }}
</button>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button>
<button
type="submit"
form="create-group-form"
:disabled="submitting"
class="btn btn-primary"
data-tour="group-form-submit"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t("admin.groups.creating") : t("common.create") }}
</button>
</div>
</template>
</BaseDialog>
<!-- Edit Group Modal -->
<BaseDialog
:show="showEditModal"
:title="t('admin.groups.editGroup')"
width="normal"
@close="closeEditModal"
>
<form
v-if="editingGroup"
id="edit-group-form"
@submit.prevent="handleUpdateGroup"
class="space-y-5"
>
<div>
<label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input
v-model="editForm.name"
type="text"
required
class="input"
data-tour="edit-group-form-name"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.description")
}}</label>
<textarea
v-model="editForm.description"
rows="3"
class="input"
></textarea>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.platform")
}}</label>
<Select
v-model="editForm.platform"
:options="platformOptions"
:disabled="true"
data-tour="group-form-platform"
/>
<p class="input-hint">{{ t("admin.groups.platformNotEditable") }}</p>
</div>
<!-- 从分组复制账号(编辑时) -->
<div v-if="copyAccountsGroupOptionsForEdit.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.copyAccounts.title") }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.copyAccounts.tooltipEdit") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div
v-if="editForm.copy_accounts_from_group_ids.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span
v-for="groupId in editForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
copyAccountsGroupOptionsForEdit.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button
type="button"
@click="
editForm.copy_accounts_from_group_ids =
editForm.copy_accounts_from_group_ids.filter(
(id) => id !== groupId,
)
"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="
(e) => {
const val = Number((e.target as HTMLSelectElement).value);
if (
val &&
!editForm.copy_accounts_from_group_ids.includes(val)
) {
editForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
}
"
>
<option value="">
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option
v-for="opt in copyAccountsGroupOptionsForEdit"
:key="opt.value"
:value="opt.value"
:disabled="
editForm.copy_accounts_from_group_ids.includes(opt.value)
"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">
{{ t("admin.groups.copyAccounts.hintEdit") }}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.groups.form.rateMultiplier")
}}</label>
<input
v-model.number="editForm.rate_multiplier"
type="number"
step="0.001"
min="0.001"
required
class="input"
data-tour="group-form-multiplier"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
<input
v-model.number="editForm.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<ReasoningEffortPolicyFields
v-if="supportsReasoningEffortPolicyPlatform(editForm.platform)"
ref="editReasoningEffortPolicyRef"
id-prefix="edit-group-reasoning"
:platform="editForm.platform"
v-model:max-effort="editForm.max_reasoning_effort"
v-model:mappings="editForm.reasoning_effort_mappings"
/>
<div v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.form.exclusive") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<!-- Tooltip Popover -->
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="mb-2 text-xs font-medium">
{{ t("admin.groups.exclusiveTooltip.title") }}
</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.exclusiveTooltip.description") }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span
class="inline-flex items-center gap-1 text-primary-400"
><Icon name="lightbulb" size="xs" />
{{ t("admin.groups.exclusiveTooltip.example") }}</span
>
{{ t("admin.groups.exclusiveTooltip.exampleContent") }}
</p>
</div>
<!-- Arrow -->
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
editForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span>
</div>
</div>
<div>
<label class="input-label">{{ t("admin.groups.form.status") }}</label>
<Select v-model="editForm.status" :options="editStatusOptions" />
</div>
<!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4">
<div>
<label class="input-label">{{
t("admin.groups.subscription.type")
}}</label>
<Select
v-model="editForm.subscription_type"
:options="subscriptionTypeOptions"
:disabled="true"
/>
<p class="input-hint">
{{ t("admin.groups.subscription.typeNotEditable") }}
</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
<div
v-if="editForm.subscription_type === 'subscription'"
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label class="input-label">{{
t("admin.groups.subscription.dailyLimit")
}}</label>
<input
v-model.number="editForm.daily_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.subscription.weeklyLimit")
}}</label>
<input
v-model.number="editForm.weekly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.subscription.monthlyLimit")
}}</label>
<input
v-model.number="editForm.monthly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
</div>
</div>
<div class="border-t pt-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.modelsList.title") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.modelsList.hint") }}
</p>
</div>
<button
type="button"
@click="editModelsListState.enabled = !editModelsListState.enabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors',
editModelsListState.enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editModelsListState.enabled ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
</div>
<div
v-if="editModelsListState.enabled"
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50/50 dark:border-dark-600 dark:bg-dark-800/40"
>
<div
v-if="!editModelsListLoading && editModelsListState.items.length > 0"
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
>
<span class="text-gray-500 dark:text-gray-400">
{{
t("admin.groups.modelsList.selectedSummary", {
selected: editModelsListSelectedCount,
total: editModelsListState.items.length,
})
}}
</span>
<div class="flex items-center gap-1.5">
<button
type="button"
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
@click="selectAllModelsListItems(editModelsListState)"
>
{{ t("admin.groups.modelsList.selectAll") }}
</button>
<button
type="button"
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
@click="invertModelsListSelection(editModelsListState)"
>
{{ t("admin.groups.modelsList.invertSelection") }}
</button>
</div>
</div>
<div
class="max-h-64 space-y-2 overflow-y-auto p-2"
>
<p v-if="editModelsListLoading" class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.modelsList.loading") }}
</p>
<p
v-else-if="editModelsListState.items.length === 0"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.modelsList.empty") }}
</p>
<div
v-for="(item, index) in editModelsListState.items"
:key="item.id"
class="flex items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2 dark:border-dark-600 dark:bg-dark-800"
>
<input
v-model="item.selected"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="min-w-0 flex-1 break-all text-sm text-gray-700 dark:text-gray-300">
{{ item.id }}
</span>
<button
type="button"
:disabled="index === 0"
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
@click="moveEditModelsListItem(index, index - 1)"
>
<Icon name="arrowUp" size="sm" />
</button>
<button
type="button"
:disabled="index === editModelsListState.items.length - 1"
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dark-600 dark:hover:text-gray-200"
@click="moveEditModelsListItem(index, index + 1)"
>
<Icon name="arrowDown" size="sm" />
</button>
</div>
</div>
</div>
</div>
<!-- 图片生成计费配置 -->
<div
v-if="supportsImagePricingPlatform(editForm.platform)"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t(imagePricingI18nKey(editForm.platform, "title")) }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t(imagePricingI18nKey(editForm.platform, "description")) }}
</p>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.allow_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(imagePricingI18nKey(editForm.platform, "allowImageGeneration")) }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.image_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(imagePricingI18nKey(editForm.platform, "independentMultiplier")) }}
</label>
</div>
<div
v-if="editForm.image_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t(imagePricingI18nKey(editForm.platform, "imageMultiplier"))
}}</label>
<input
v-model.number="editForm.image_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
<input
v-model.number="editForm.image_price_1k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_1k')"
/>
</div>
<div>
<label class="input-label">2K ($)</label>
<input
v-model.number="editForm.image_price_2k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_2k')"
/>
</div>
<div>
<label class="input-label">4K ($)</label>
<input
v-model.number="editForm.image_price_4k"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getImagePricePlaceholder(editForm.platform, 'image_price_4k')"
/>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(imagePricingI18nKey(editForm.platform, "modeHint")) }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t(imagePricingI18nKey(editForm.platform, "finalPricePreview")) }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in editImageFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
<div v-if="editForm.platform === 'gemini' && editForm.allow_image_generation" class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<label
class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
<input
v-model="editForm.allow_batch_image_generation"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowBatchImageGeneration") }}
</label>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.batchSectionHint") }}
</p>
<div
v-if="editForm.allow_batch_image_generation"
class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2"
>
<div>
<label class="input-label">{{
t("admin.groups.imagePricing.batchDiscountMultiplier")
}}</label>
<input
v-model.number="editForm.batch_image_discount_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="0.5"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.imagePricing.batchHoldMultiplier")
}}</label>
<input
v-model.number="editForm.batch_image_hold_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="0.6"
/>
</div>
</div>
</div>
<p
v-else-if="editForm.platform !== 'gemini'"
class="mt-4 border-t border-dashed border-gray-200 pt-4 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }}
</p>
</div>
<!-- 视频生成计费配置(仅 Grok 平台) -->
<div
v-if="supportsVideoPricingPlatform(editForm.platform)"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t(videoPricingI18nKey("title")) }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t(videoPricingI18nKey("description")) }}
</p>
<div class="mb-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.video_rate_independent"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t(videoPricingI18nKey("independentMultiplier")) }}
</label>
</div>
<div
v-if="editForm.video_rate_independent"
class="mb-4"
>
<label class="input-label">{{
t(videoPricingI18nKey("videoMultiplier"))
}}</label>
<input
v-model.number="editForm.video_rate_multiplier"
type="number"
step="0.0001"
min="0"
class="input"
placeholder="1"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="editForm.video_price_480p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_480p')"
/>
</div>
<div>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="editForm.video_price_720p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_720p')"
/>
</div>
<div>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="editForm.video_price_1080p"
type="number"
step="0.001"
min="0"
class="input"
:placeholder="getVideoPricePlaceholder(editForm.platform, 'video_price_1080p')"
/>
</div>
</div>
<div
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
data-testid="edit-grok-video-model-prices"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
</p>
<div class="mt-3 space-y-3">
<div
v-for="family in videoModelPriceFamilyRows(editForm.video_model_prices)"
:key="family.key"
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
>
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ family.label }}
</div>
<label
v-for="resolution in grokVideoPriceResolutions"
:key="resolution.key"
class="block"
>
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
{{ resolution.label }} ($/s)
</span>
<input
v-model.number="editForm.video_model_prices[family.key][resolution.key]"
type="number"
step="0.001"
min="0"
class="input"
:data-testid="`edit-grok-video-price-${family.key}-${resolution.key}`"
/>
</label>
</div>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t(videoPricingI18nKey("modeHint")) }}
</p>
<div class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div class="mb-1 font-medium">
{{ t(videoPricingI18nKey("finalPricePreview")) }}
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="item in editVideoFinalPricePreview"
:key="item.label"
>
{{ item.label }}: {{ item.value }}
</div>
</div>
</div>
</div>
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
<div v-if="editForm.subscription_type === 'subscription'" class="border-t pt-4">
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.peak_rate_enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>{{ t("admin.groups.peakRate.enable") }}</span>
</label>
</div>
<div
v-if="editForm.peak_rate_enabled"
class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
<input
v-model="editForm.peak_start"
type="time"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakEnd") }}</label>
<input
v-model="editForm.peak_end"
type="time"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakMultiplier") }}</label>
<input
v-model.number="editForm.peak_rate_multiplier"
type="number"
step="0.001"
min="0"
class="input"
placeholder="1"
:title="t('admin.groups.peakRate.multiplierHint')"
/>
</div>
</div>
</div>
<!-- 分组利润控制(五个平台 token 请求) -->
<div v-if="isProfitControlPlatform(editForm.platform)" class="border-t pt-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="editForm.profit_control_enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>{{ t("admin.groups.profitControl.enable") }}</span>
</label>
<p class="mb-3 mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
editForm.profit_control_enabled
? t("admin.groups.profitControl.enabledHint")
: t("admin.groups.profitControl.disabledHint")
}}
</p>
<div
v-if="editForm.profit_control_enabled"
class="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<div>
<label class="input-label">{{ t("admin.groups.profitControl.minMargin") }}</label>
<input
v-model.number="editForm.profit_min_margin_percent"
type="number"
step="0.1"
min="0"
max="99.99"
class="input"
placeholder="0"
:title="t('admin.groups.profitControl.minMarginHint')"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.profitControl.safetyBuffer") }}</label>
<input
v-model.number="editForm.profit_safety_buffer_percent"
type="number"
step="0.1"
min="0"
max="99.99"
class="input"
placeholder="0"
:title="t('admin.groups.profitControl.safetyBufferHint')"
/>
</div>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.supportedScopes.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.supportedScopes.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('claude')"
@change="toggleEditScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.claude")
}}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="
editForm.supported_model_scopes.includes('gemini_text')
"
@change="toggleEditScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiText")
}}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="
editForm.supported_model_scopes.includes('gemini_image')
"
@change="toggleEditScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiImage")
}}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div>
<!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.mcpXml.title") }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.mcpXml.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.mcp_xml_inject = !editForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
editForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span>
</div>
</div>
<!-- Claude Code 客户端限制(仅 anthropic 平台) -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.claudeCode.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.claudeCode.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.claude_code_only = !editForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
editForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span>
</div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div v-if="editForm.claude_code_only" class="mt-3">
<label class="input-label">{{
t("admin.groups.claudeCode.fallbackGroup")
}}</label>
<Select
v-model="editForm.fallback_group_id"
:options="fallbackGroupOptionsForEdit"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div>
</div>
<!-- Codex 网页搜索按次计费(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.webSearchPricing.title") }}
</h4>
<div>
<label class="input-label">{{
t("admin.groups.webSearchPricing.pricePerCall")
}}</label>
<input
v-model.number="editForm.web_search_price_per_call"
type="number"
step="0.001"
min="0"
placeholder="0.01"
class="input"
/>
<p class="input-hint">
{{ t("admin.groups.webSearchPricing.pricePerCallHint") }}
</p>
<div
class="mt-2 rounded-lg bg-gray-50 p-3 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300"
>
{{
t("admin.groups.webSearchPricing.finalPricePreview", {
price: editWebSearchFinalPricePreview,
})
}}
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(editForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="editForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in editForm.model_pricing" :key="index" :entry="entry" :platform="editForm.platform" hide-token-intervals @update="editForm.model_pricing[index] = $event" @remove="editForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.explicitPricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.explicitPricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.explicitPricing.searchPricePer1k") }}</label>
<input
v-model.number="editForm.search_price_per_1k"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.explicitPricing.pricePlaceholder')"
data-testid="edit-search-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="editForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="editForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="editForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiLive.title") }}
</h4>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiLive.allow")
}}</label>
<button
type="button"
@click="toggleLive('edit')"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.allow_live
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="editForm.allow_live ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiLive.hint") }}
</p>
</div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.openaiMessages.title") }}
</h4>
<!-- 允许 Messages 调度开关 -->
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiMessages.allowDispatch")
}}</label>
<button
type="button"
@click="
editForm.allow_messages_dispatch =
!editForm.allow_messages_dispatch
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
editForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ t("admin.groups.openaiMessages.allowDispatchHint") }}
</p>
<div v-if="editForm.allow_messages_dispatch" class="mt-3">
<div
class="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
>
<div
class="border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
<label
class="text-sm font-medium text-gray-900 dark:text-white"
>{{
t("admin.groups.openaiMessages.familyMappingTitle")
}}</label
>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.openaiMessages.familyMappingHint") }}
</p>
</div>
<div class="p-4">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.opusModel")
}}</label>
<input
v-model="editForm.opus_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.opusModelPlaceholder')
"
class="input"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.sonnetModel")
}}</label>
<input
v-model="editForm.sonnet_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.sonnetModelPlaceholder')
"
class="input"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.haikuModel")
}}</label>
<input
v-model="editForm.haiku_mapped_model"
type="text"
:placeholder="
t('admin.groups.openaiMessages.haikuModelPlaceholder')
"
class="input"
/>
</div>
</div>
</div>
</div>
<div
class="mt-5 relative overflow-hidden rounded-xl border border-primary-200 bg-white shadow-sm dark:border-primary-900/50 dark:bg-dark-800"
>
<div
class="border-b border-primary-100 bg-primary-50/80 px-4 py-3 dark:border-primary-900/40 dark:bg-primary-900/20"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-primary-500"></div>
<label
class="text-sm font-medium text-primary-900 dark:text-primary-100"
>{{
t("admin.groups.openaiMessages.exactMappingTitle")
}}</label
>
</div>
<p
class="mt-1 text-xs text-primary-600/90 dark:text-primary-400/90"
>
{{ t("admin.groups.openaiMessages.exactMappingHint") }}
</p>
</div>
</div>
</div>
<div class="p-4 bg-gray-50/30 dark:bg-dark-800/30">
<div
v-if="editForm.exact_model_mappings.length === 0"
class="flex items-center justify-between gap-3 rounded-xl border-2 border-dashed border-primary-200 bg-white px-5 py-4 text-sm text-primary-700 transition-colors hover:border-primary-300 dark:border-primary-900/40 dark:bg-dark-800 dark:text-primary-300 dark:hover:border-primary-800"
>
<span>{{
t("admin.groups.openaiMessages.noExactMappings")
}}</span>
<button
type="button"
@click="addEditMessagesDispatchMapping"
class="flex items-center gap-1.5 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
<div v-else class="space-y-3">
<div
v-for="row in editForm.exact_model_mappings"
:key="getEditMessagesDispatchRowKey(row)"
class="group relative rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-primary-300 hover:shadow-md dark:border-dark-600 dark:bg-dark-700 dark:hover:border-primary-700"
>
<div class="flex items-center gap-4">
<div
class="grid flex-1 gap-4 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-start"
>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.claudeModel")
}}</label>
<input
v-model="row.claude_model"
type="text"
:placeholder="
t(
'admin.groups.openaiMessages.claudeModelPlaceholder',
)
"
class="input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
<div
class="hidden md:flex md:justify-center md:pt-7 text-primary-300 dark:text-primary-700"
>
<Icon
name="arrowRight"
size="sm"
class="transition-transform group-hover:translate-x-1"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.openaiMessages.targetModel")
}}</label>
<input
v-model="row.target_model"
type="text"
:placeholder="
t(
'admin.groups.openaiMessages.targetModelPlaceholder',
)
"
class="input bg-gray-50 focus:bg-white dark:bg-dark-800 dark:focus:bg-dark-900"
/>
</div>
</div>
<button
type="button"
@click="removeEditMessagesDispatchMapping(row)"
class="mt-6 flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="
t('admin.groups.openaiMessages.removeExactMapping')
"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
<button
type="button"
@click="addEditMessagesDispatchMapping"
class="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-gray-300 bg-white py-3 text-sm font-medium text-gray-500 transition-all hover:border-primary-300 hover:bg-primary-50/50 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-primary-800 dark:hover:bg-primary-900/20 dark:hover:text-primary-400"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.openaiMessages.addExactMapping") }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) -->
<div
v-if="
['openai', 'antigravity', 'anthropic', 'gemini'].includes(
editForm.platform,
)
"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ t("admin.groups.accountFilters.title") }}
</h4>
<!-- require_oauth_only toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
editForm.require_oauth_only
? t("admin.groups.accountFilters.oauthOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
<button
type="button"
@click="
editForm.require_oauth_only = !editForm.require_oauth_only
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
editForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
<!-- require_privacy_set toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
editForm.require_privacy_set
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
<button
type="button"
@click="
editForm.require_privacy_set = !editForm.require_privacy_set
"
class="relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="
editForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="
editForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
"
/>
</button>
</div>
</div>
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if="
['anthropic', 'antigravity'].includes(editForm.platform) &&
editForm.subscription_type !== 'subscription'
"
class="border-t pt-4"
>
<label class="input-label">{{
t("admin.groups.invalidRequestFallback.title")
}}</label>
<Select
v-model="editForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptionsForEdit"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div>
<!-- 模型路由配置(仅 anthropic 平台) -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.groups.modelRouting.title") }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div
class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
>
<div
class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"
>
<p class="text-xs leading-relaxed text-gray-300">
{{ t("admin.groups.modelRouting.tooltip") }}
</p>
<div
class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"
></div>
</div>
</div>
</div>
</div>
<!-- 启用开关 -->
<div class="flex items-center gap-3 mb-3">
<button
type="button"
@click="
editForm.model_routing_enabled = !editForm.model_routing_enabled
"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
editForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span>
</div>
<p
v-if="!editForm.model_routing_enabled"
class="text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t("admin.groups.modelRouting.disabledHint") }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.modelRouting.noRulesHint") }}
</p>
<!-- 路由规则列表(仅在启用时显示) -->
<div v-if="editForm.model_routing_enabled" class="space-y-3">
<div
v-for="rule in editModelRoutingRules"
:key="getEditRuleRenderKey(rule)"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="flex items-start gap-3">
<div class="flex-1 space-y-2">
<div>
<label class="input-label text-xs">{{
t("admin.groups.modelRouting.modelPattern")
}}</label>
<input
v-model="rule.pattern"
type="text"
class="input text-sm"
:placeholder="
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/>
</div>
<div>
<label class="input-label text-xs">{{
t("admin.groups.modelRouting.accounts")
}}</label>
<!-- 已选账号标签 -->
<div
v-if="rule.accounts.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span
v-for="account in rule.accounts"
:key="account.id"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ account.name }}
<button
type="button"
@click="removeSelectedAccount(rule, account.id, true)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 账号搜索输入框 -->
<div class="relative account-search-container">
<input
v-model="
accountSearchKeyword[getEditRuleSearchKey(rule)]
"
type="text"
class="input text-sm"
:placeholder="
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@input="searchAccountsByRule(rule, true)"
@focus="onAccountSearchFocus(rule, true)"
/>
<!-- 搜索结果下拉框 -->
<div
v-if="
showAccountDropdown[getEditRuleSearchKey(rule)] &&
accountSearchResults[getEditRuleSearchKey(rule)]
?.length > 0
"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in accountSearchResults[
getEditRuleSearchKey(rule)
]"
:key="account.id"
type="button"
@click="selectAccount(rule, account, true)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{
'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled="
rule.accounts.some((a) => a.id === account.id)
"
>
<span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400"
>#{{ account.id }}</span
>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div>
</div>
<button
type="button"
@click="removeEditRoutingRule(rule)"
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
:title="t('admin.groups.modelRouting.removeRule')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- 添加规则按钮(仅在启用时显示) -->
<button
v-if="editForm.model_routing_enabled"
type="button"
@click="addEditRoutingRule"
class="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="plus" size="sm" />
{{ t("admin.groups.modelRouting.addRule") }}
</button>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeEditModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button>
<button
type="submit"
form="edit-group-form"
:disabled="submitting"
class="btn btn-primary"
data-tour="group-form-submit"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t("admin.groups.updating") : t("common.update") }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.groups.deleteGroup')"
:message="deleteConfirmMessage"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<ConfirmDialog
:show="showUnsupportedLiveConfirm"
:title="t('admin.groups.openaiLive.unsupportedTitle')"
:message="t('admin.groups.openaiLive.unsupportedMessage')"
:confirm-text="t('admin.groups.openaiLive.enableAnyway')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmUnsupportedLive"
@cancel="cancelUnsupportedLive"
/>
<!-- Sort Order Modal -->
<BaseDialog
:show="showSortModal"
:title="t('admin.groups.sortOrder')"
width="normal"
@close="closeSortModal"
>
<div class="space-y-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.groups.sortOrderHint") }}
</p>
<VueDraggable
v-model="sortableGroups"
:animation="200"
class="space-y-2"
>
<div
v-for="group in sortableGroups"
:key="group.id"
class="flex cursor-grab items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-shadow hover:shadow-md active:cursor-grabbing dark:border-dark-600 dark:bg-dark-700"
>
<div class="text-gray-400">
<Icon name="menu" size="md" />
</div>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">
{{ group.name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<span
:class="[
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
group.platform === 'anthropic'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: group.platform === 'openai'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: group.platform === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: group.platform === 'grok'
? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ t("admin.groups.platforms." + group.platform) }}
</span>
</div>
</div>
<div class="text-sm text-gray-400">#{{ group.id }}</div>
</div>
</VueDraggable>
</div>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeSortModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button>
<button
@click="saveSortOrder"
:disabled="sortSubmitting"
class="btn btn-primary"
>
<svg
v-if="sortSubmitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ sortSubmitting ? t("common.saving") : t("common.save") }}
</button>
</div>
</template>
</BaseDialog>
<!-- Composite Routes Modal -->
<BaseDialog
:show="showCompositeRoutesModal"
:title="
compositeRoutesGroup
? t('admin.groups.compositeRoutes.titleWithGroup', {
name: compositeRoutesGroup.name,
})
: t('admin.groups.compositeRoutes.title')
"
width="wide"
@close="closeCompositeRoutesModal"
>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<section class="min-w-0">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t("admin.groups.compositeRoutes.routes") }}
</h3>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="compositeRoutesLoading"
@click="loadCompositeRoutes"
>
<Icon
name="refresh"
size="sm"
:class="compositeRoutesLoading ? 'animate-spin' : ''"
/>
</button>
</div>
<div
class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600"
>
<div
v-if="compositeRoutesLoading"
class="flex h-36 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t("common.loading") }}
</div>
<div
v-else-if="compositeRoutes.length === 0"
class="flex h-36 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.compositeRoutes.empty") }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-dark-600">
<thead class="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:bg-dark-800 dark:text-gray-400">
<tr>
<th class="px-3 py-2">
{{ t("admin.groups.compositeRoutes.publicModel") }}
</th>
<th class="px-3 py-2">
{{ t("admin.groups.compositeRoutes.target") }}
</th>
<th class="px-3 py-2">
{{ t("admin.groups.compositeRoutes.scope") }}
</th>
<th class="px-3 py-2 text-right">
{{ t("admin.groups.columns.actions") }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr
v-for="route in compositeRoutes"
:key="route.id"
:class="!route.enabled && 'opacity-60'"
>
<td class="max-w-[15rem] px-3 py-2">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ route.public_model }}
</div>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span class="badge badge-gray">{{
compositeRouteMatchLabel(route.match_type)
}}</span>
<span
v-if="!route.enabled"
class="badge badge-danger"
>
{{ t("admin.accounts.status.inactive") }}
</span>
</div>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1.5 text-gray-900 dark:text-white">
<PlatformIcon :platform="route.target_platform" size="xs" />
<span>{{ formatCompositePlatform(route.target_platform) }}</span>
</div>
<div class="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
{{ route.upstream_model || route.public_model }}
</div>
</td>
<td class="px-3 py-2">
<div class="text-gray-700 dark:text-gray-300">
{{ formatCompositeEndpoint(route.endpoint) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.compositeRoutes.priority") }}:
{{ route.priority }}
</div>
</td>
<td class="px-3 py-2">
<div class="flex justify-end gap-1">
<button
type="button"
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('common.edit')"
@click="editCompositeRoute(route)"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
class="rounded p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
@click="deleteCompositeRoute(route)"
>
<Icon name="trash" size="sm" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="space-y-5">
<form class="space-y-3" @submit.prevent="saveCompositeRoute">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{
compositeRouteEditingId
? t("admin.groups.compositeRoutes.editRoute")
: t("admin.groups.compositeRoutes.addRoute")
}}
</h3>
<button
v-if="compositeRouteEditingId"
type="button"
class="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="resetCompositeRouteForm"
>
{{ t("common.cancel") }}
</button>
</div>
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.publicModel")
}}</label>
<input
v-model.trim="compositeRouteForm.public_model"
type="text"
class="input"
required
placeholder="openrouter/gpt-5"
/>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.matchType")
}}</label>
<Select
v-model="compositeRouteForm.match_type"
:options="compositeRouteMatchOptions"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.endpoint")
}}</label>
<Select
v-model="compositeRouteForm.endpoint"
:options="compositeRouteEndpointOptions"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.targetPlatform")
}}</label>
<Select
v-model="compositeRouteForm.target_platform"
:options="compositeRoutePlatformOptions"
/>
</div>
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.priority")
}}</label>
<input
v-model.number="compositeRouteForm.priority"
type="number"
min="1"
step="1"
class="input"
/>
</div>
</div>
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.upstreamModel")
}}</label>
<input
v-model.trim="compositeRouteForm.upstream_model"
type="text"
class="input"
placeholder="gpt-5"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.compositeRoutes.upstreamModelHint") }}
</p>
</div>
<div>
<label class="input-label">{{
t("admin.groups.compositeRoutes.notes")
}}</label>
<textarea
v-model.trim="compositeRouteForm.notes"
rows="2"
class="input"
></textarea>
</div>
<div class="flex items-center justify-between gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="compositeRouteForm.enabled"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
{{ t("admin.groups.compositeRoutes.enabled") }}
</label>
<button
type="submit"
class="btn btn-primary"
:disabled="compositeRouteSaving"
>
<Icon
v-if="!compositeRouteSaving"
name="check"
size="sm"
class="mr-2"
/>
{{ compositeRouteEditingId ? t("common.update") : t("common.create") }}
</button>
</div>
</form>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t("admin.groups.compositeRoutes.preview") }}
</h3>
<div class="space-y-3">
<input
v-model.trim="compositePreviewModel"
type="text"
class="input"
placeholder="openrouter/gpt-5"
@keyup.enter="previewCompositeRoute"
/>
<div class="flex gap-2">
<Select
v-model="compositePreviewEndpoint"
:options="compositeRouteEndpointOptions"
class="min-w-0 flex-1"
/>
<button
type="button"
class="btn btn-secondary"
:disabled="compositePreviewLoading || !compositePreviewModel"
@click="previewCompositeRoute"
>
<Icon name="play" size="sm" />
</button>
</div>
<div
v-if="compositePreviewDecision"
class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-dark-600 dark:bg-dark-800"
>
<div class="mb-2 flex items-center gap-2">
<span
:class="[
'badge',
compositePreviewDecision.matched
? 'badge-success'
: 'badge-danger',
]"
>
{{
compositePreviewDecision.matched
? t("admin.groups.compositeRoutes.matched")
: t("admin.groups.compositeRoutes.notMatched")
}}
</span>
<span class="badge badge-gray">
{{
compositeRouteSourceLabel(
compositePreviewDecision.source,
)
}}
</span>
</div>
<div
v-if="compositePreviewDecision.matched"
class="space-y-1 text-gray-700 dark:text-gray-300"
>
<div>
{{ t("admin.groups.compositeRoutes.targetPlatform") }}:
{{
formatCompositePlatform(
compositePreviewDecision.target_platform,
)
}}
</div>
<div class="break-all">
{{ t("admin.groups.compositeRoutes.upstreamModel") }}:
{{ compositePreviewDecision.upstream_model }}
</div>
</div>
<div
v-else
class="text-gray-500 dark:text-gray-400"
>
{{ compositePreviewDecision.reason }}
</div>
</div>
</div>
</div>
</section>
</div>
<template #footer>
<div class="flex justify-end pt-4">
<button
type="button"
class="btn btn-secondary"
@click="closeCompositeRoutesModal"
>
{{ t("common.close") }}
</button>
</div>
</template>
</BaseDialog>
<!-- Group Rate Multipliers Modal -->
<GroupRateMultipliersModal
:show="showRateMultipliersModal"
:group="rateMultipliersGroup"
@close="showRateMultipliersModal = false"
@success="loadGroups"
/>
<!-- Group RPM Overrides Modal -->
<GroupRPMOverridesModal
:show="showRPMOverridesModal"
:group="rpmOverridesGroup"
@close="showRPMOverridesModal = false"
@success="loadGroups"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/stores/app";
import { useOnboardingStore } from "@/stores/onboarding";
import { adminAPI } from "@/api/admin";
import type {
AdminGroup,
CompositeModelRoute,
CompositeModelRouteInput,
CompositeRouteDecision,
CompositeRouteEndpoint,
CompositeRouteMatchType,
GroupPlatform,
SubscriptionType,
} from "@/types";
import type { Column } from "@/components/common/types";
import AppLayout from "@/components/layout/AppLayout.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import DataTable from "@/components/common/DataTable.vue";
import Pagination from "@/components/common/Pagination.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import Select from "@/components/common/Select.vue";
import PlatformIcon from "@/components/common/PlatformIcon.vue";
import Icon from "@/components/icons/Icon.vue";
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue";
import PricingEntryCard from "@/components/admin/channel/PricingEntryCard.vue";
import type { PricingFormEntry } from "@/components/admin/channel/types";
import {
apiIntervalsToForm,
formIntervalsToAPI,
mTokToPerToken,
perTokenToMTok,
toNullableNumber,
} from "@/components/admin/channel/types";
import type { ChannelModelPricing } from "@/api/admin/channels";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
import { useKeyedDebouncedSearch } from "@/composables/useKeyedDebouncedSearch";
import { getPersistedPageSize } from "@/composables/usePersistedPageSize";
import {
createDefaultMessagesDispatchFormState,
messagesDispatchConfigToFormState,
messagesDispatchFormStateToConfig,
resetMessagesDispatchFormState,
type MessagesDispatchMappingRow,
} from "./groupsMessagesDispatch";
import {
buildModelsListConfig,
createModelsListState as createInitialModelsListState,
invertModelsListSelection,
moveModelsListItem,
selectAllModelsListItems,
setModelsListCandidates,
} from "./groupsModelsList";
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
import {
isProfitControlPlatform,
profitPercentToDecimal,
profitDecimalToPercent,
validateProfitControlFormState,
type ProfitControlFormState,
} from "./groupsProfitControl";
import {
normalizeReasoningEffortForPlatform,
reasoningEffortMappingsToAPI,
reasoningEffortMappingsToRows,
supportsReasoningEffortPolicyPlatform,
type ReasoningEffortMappingRow,
} from "./groupsReasoningEffort";
import {
getDefaultImagePreviewPrice,
getDefaultVideoPreviewPrice,
getImagePricePlaceholder,
getVideoPricePlaceholder,
imagePricingI18nKey,
supportsImagePricingPlatform,
supportsVideoPricingPlatform,
videoPricingI18nKey,
} from "./groupsImagePricing";
import {
createVideoModelPricesForm,
grokVideoPriceResolutions,
serializeVideoModelPrices,
videoModelPriceFamilyRows,
} from "./groupsVideoModelPricing";
const emptyGroupPricing = (): PricingFormEntry => ({
models: [],
billing_mode: "token",
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [],
});
const addGroupPricing = (entries: PricingFormEntry[]) =>
entries.push(emptyGroupPricing());
const groupPricingFromAPI = (
pricing: ChannelModelPricing[] | undefined,
): PricingFormEntry[] =>
(pricing || []).map((entry) => ({
models: entry.models || [],
billing_mode: entry.billing_mode || "token",
input_price: perTokenToMTok(entry.input_price),
output_price: perTokenToMTok(entry.output_price),
cache_write_price: perTokenToMTok(entry.cache_write_price),
cache_read_price: perTokenToMTok(entry.cache_read_price),
image_input_price: perTokenToMTok(entry.image_input_price),
image_output_price: perTokenToMTok(entry.image_output_price),
per_request_price: entry.per_request_price,
intervals: apiIntervalsToForm(entry.intervals || []),
}));
const groupPricingToAPI = (
pricing: PricingFormEntry[],
platform: string,
): ChannelModelPricing[] =>
pricing
.filter((entry) => entry.models.length > 0)
.map((entry) => ({
platform,
models: entry.models,
billing_mode: entry.billing_mode,
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_input_price: mTokToPerToken(entry.image_input_price),
image_output_price: mTokToPerToken(entry.image_output_price),
per_request_price: toNullableNumber(entry.per_request_price),
intervals:
entry.billing_mode === "token"
? []
: formIntervalsToAPI(entry.intervals || []),
}));
const { t } = useI18n();
const appStore = useAppStore();
const onboardingStore = useOnboardingStore();
const ALWAYS_VISIBLE_COLUMNS = new Set(["name", "actions"]);
// Default hidden columns (hidden on first load / after schema bumps).
const DEFAULT_HIDDEN_COLUMNS = ["id"];
const HIDDEN_COLUMNS_KEY = "group-hidden-columns";
// Bump when adding new default-hidden columns so existing admins pick them up once.
const COLUMN_SETTINGS_VERSION_KEY = "group-column-settings-version";
const COLUMN_SETTINGS_VERSION = 2;
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
2: ["id"],
};
const allColumns = computed<Column[]>(() => [
{ key: "name", label: t("admin.groups.columns.name"), sortable: true },
{ key: "id", label: t("admin.groups.columns.id"), sortable: true },
{
key: "platform",
label: t("admin.groups.columns.platform"),
sortable: true,
},
{
key: "billing_type",
label: t("admin.groups.columns.billingType"),
sortable: true,
},
{
key: "rate_multiplier",
label: t("admin.groups.columns.rateMultiplier"),
sortable: true,
},
{
key: "is_exclusive",
label: t("admin.groups.columns.type"),
sortable: true,
},
{
key: "account_count",
label: t("admin.groups.columns.accounts"),
sortable: true,
},
{
key: "capacity",
label: t("admin.groups.columns.capacity"),
sortable: false,
},
{ key: "usage", label: t("admin.groups.columns.usage"), sortable: false },
{ key: "status", label: t("admin.groups.columns.status"), sortable: true },
{ key: "actions", label: t("admin.groups.columns.actions"), sortable: false },
]);
const toggleableColumns = computed(() =>
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key)),
);
const hiddenColumns = reactive<Set<string>>(new Set());
const showColumnDropdown = ref(false);
const columnDropdownRef = ref<HTMLElement | null>(null);
const getValidHiddenColumnKeys = () =>
new Set(toggleableColumns.value.map((col) => col.key));
const loadSavedColumns = () => {
hiddenColumns.clear();
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY);
const validKeys = getValidHiddenColumnKeys();
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
parsed
.filter(
(key): key is string =>
typeof key === "string" && validKeys.has(key),
)
.forEach((key) => hiddenColumns.add(key));
}
// Existing admins: auto-hide columns newly added as default-hidden.
const storedVersion = Number(
localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? "1",
);
if (storedVersion < COLUMN_SETTINGS_VERSION) {
let mutated = false;
for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) {
for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) {
if (validKeys.has(key) && !hiddenColumns.has(key)) {
hiddenColumns.add(key);
mutated = true;
}
}
}
if (mutated) {
saveColumnsToStorage();
} else {
localStorage.setItem(
COLUMN_SETTINGS_VERSION_KEY,
String(COLUMN_SETTINGS_VERSION),
);
}
}
} else {
DEFAULT_HIDDEN_COLUMNS.forEach((key) => {
if (validKeys.has(key)) hiddenColumns.add(key);
});
saveColumnsToStorage();
}
} catch (error) {
console.error("Failed to load group column settings:", error);
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key));
}
};
const saveColumnsToStorage = () => {
try {
const validKeys = getValidHiddenColumnKeys();
const keys = [...hiddenColumns].filter((key) => validKeys.has(key));
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify(keys));
localStorage.setItem(
COLUMN_SETTINGS_VERSION_KEY,
String(COLUMN_SETTINGS_VERSION),
);
} catch (error) {
console.error("Failed to save group column settings:", error);
}
};
const isColumnVisible = (key: string) => !hiddenColumns.has(key);
const hasVisibleUsageSummaryConsumer = computed(
() => isColumnVisible("usage") || isColumnVisible("billing_type"),
);
const hasVisibleCapacityColumn = computed(() => isColumnVisible("capacity"));
const toggleColumn = (key: string) => {
const validKeys = getValidHiddenColumnKeys();
if (!validKeys.has(key)) return;
const wasHidden = hiddenColumns.has(key);
if (wasHidden) {
hiddenColumns.delete(key);
} else {
hiddenColumns.add(key);
}
saveColumnsToStorage();
if (wasHidden && (key === "usage" || key === "billing_type")) {
loadUsageSummary();
}
if (wasHidden && key === "capacity") {
loadCapacitySummary();
}
};
const columns = computed<Column[]>(() =>
allColumns.value.filter(
(col) => ALWAYS_VISIBLE_COLUMNS.has(col.key) || !hiddenColumns.has(col.key),
),
);
if (typeof window !== "undefined") {
loadSavedColumns();
}
// Filter options
const statusOptions = computed(() => [
{ value: "", label: t("admin.groups.allStatus") },
{ value: "active", label: t("admin.accounts.status.active") },
{ value: "inactive", label: t("admin.accounts.status.inactive") },
]);
const exclusiveOptions = computed(() => [
{ value: "", label: t("admin.groups.allGroups") },
{ value: "true", label: t("admin.groups.exclusive") },
{ value: "false", label: t("admin.groups.nonExclusive") },
]);
const platformOptions = computed(() => [
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
{ value: "composite", label: "Composite" },
]);
const platformFilterOptions = computed(() => [
{ value: "", label: t("admin.groups.allPlatforms") },
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
{ value: "composite", label: "Composite" },
]);
const compositeRoutePlatformOptions = computed(() => [
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
]);
const compositeRouteEndpointOptions = computed(() => [
{ value: "any", label: t("admin.groups.compositeRoutes.endpoints.any") },
{
value: "messages",
label: t("admin.groups.compositeRoutes.endpoints.messages"),
},
{
value: "count_tokens",
label: t("admin.groups.compositeRoutes.endpoints.countTokens"),
},
{
value: "responses",
label: t("admin.groups.compositeRoutes.endpoints.responses"),
},
{
value: "chat_completions",
label: t("admin.groups.compositeRoutes.endpoints.chatCompletions"),
},
{
value: "embeddings",
label: t("admin.groups.compositeRoutes.endpoints.embeddings"),
},
{ value: "images", label: t("admin.groups.compositeRoutes.endpoints.images") },
{ value: "gemini", label: t("admin.groups.compositeRoutes.endpoints.gemini") },
]);
const compositeRouteMatchOptions = computed(() => [
{ value: "exact", label: t("admin.groups.compositeRoutes.match.exact") },
{ value: "prefix", label: t("admin.groups.compositeRoutes.match.prefix") },
]);
const editStatusOptions = computed(() => [
{ value: "active", label: t("admin.accounts.status.active") },
{ value: "inactive", label: t("admin.accounts.status.inactive") },
]);
const subscriptionTypeOptions = computed(() => [
{ value: "standard", label: t("admin.groups.subscription.standard") },
{ value: "subscription", label: t("admin.groups.subscription.subscription") },
]);
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const fallbackGroupOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t("admin.groups.claudeCode.noFallback") },
];
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === "anthropic" &&
!g.claude_code_only &&
g.status === "active",
);
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name });
});
return options;
});
// 降级分组选项(编辑时)- 排除自身
const fallbackGroupOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t("admin.groups.claudeCode.noFallback") },
];
const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === "anthropic" &&
!g.claude_code_only &&
g.status === "active" &&
g.id !== currentId,
);
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name });
});
return options;
});
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const invalidRequestFallbackOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t("admin.groups.invalidRequestFallback.noFallback") },
];
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === "anthropic" &&
g.status === "active" &&
g.subscription_type !== "subscription" &&
g.fallback_group_id_on_invalid_request === null,
);
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name });
});
return options;
});
// 无效请求兜底分组选项(编辑时)- 排除自身
const invalidRequestFallbackOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t("admin.groups.invalidRequestFallback.noFallback") },
];
const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === "anthropic" &&
g.status === "active" &&
g.subscription_type !== "subscription" &&
g.fallback_group_id_on_invalid_request === null &&
g.id !== currentId,
);
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name });
});
return options;
});
const canCopyAccountsFromGroup = (targetPlatform: GroupPlatform, sourcePlatform: GroupPlatform) =>
targetPlatform === "composite" || sourcePlatform === targetPlatform;
const copyAccountsGroupLabel = (g: AdminGroup) => {
const count = g.account_count || 0;
const platform = t("admin.groups.platforms." + g.platform);
return `${g.name} - ${platform} (${t("admin.groups.accountsCount", { count })})`;
};
// 复制账号的源分组选项(创建时)- 相同平台;composite 分组可汇总各平台账号
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
(g) =>
canCopyAccountsFromGroup(createForm.platform, g.platform) &&
(g.account_count || 0) > 0,
);
return eligibleGroups.map((g) => ({
value: g.id,
label: copyAccountsGroupLabel(g),
}));
});
// 复制账号的源分组选项(编辑时)- 相同平台;composite 分组可汇总各平台账号,排除自身
const copyAccountsGroupOptionsForEdit = computed(() => {
const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter(
(g) =>
canCopyAccountsFromGroup(editForm.platform, g.platform) &&
(g.account_count || 0) > 0 &&
g.id !== currentId,
);
return eligibleGroups.map((g) => ({
value: g.id,
label: copyAccountsGroupLabel(g),
}));
});
const groups = ref<AdminGroup[]>([]);
const loading = ref(false);
type GroupUsageSummary = {
today_cost: number;
total_cost: number;
};
const usageMap = ref<Map<number, GroupUsageSummary>>(new Map());
const usageLoading = ref(false);
const capacityMap = ref<
Map<
number,
{
concurrencyUsed: number;
concurrencyMax: number;
sessionsUsed: number;
sessionsMax: number;
rpmUsed: number;
rpmMax: number;
}
>
>(new Map());
const searchQuery = ref("");
const filters = reactive({
platform: "",
status: "",
is_exclusive: "",
});
const pagination = reactive({
page: 1,
page_size: getPersistedPageSize(),
total: 0,
pages: 0,
});
const sortState = reactive({
sort_by: "sort_order",
sort_order: "asc" as "asc" | "desc",
});
let abortController: AbortController | null = null;
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteDialog = ref(false);
const pendingLiveForm = ref<"create" | "edit" | null>(null);
const showUnsupportedLiveConfirm = computed(
() => pendingLiveForm.value !== null,
);
const liveCapability = ref<{ supported: boolean; reason?: string } | null>(null);
let liveCapabilityRequest: Promise<{
supported: boolean;
reason?: string;
}> | null = null;
const showSortModal = ref(false);
const submitting = ref(false);
const sortSubmitting = ref(false);
const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null);
const duplicatingGroupIds = reactive(new Set<number>());
const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null);
const showRPMOverridesModal = ref(false);
const rpmOverridesGroup = ref<AdminGroup | null>(null);
const sortableGroups = ref<AdminGroup[]>([]);
type ConcreteGroupPlatform = Exclude<GroupPlatform, "composite">;
type CompositeRouteFormState = {
public_model: string;
match_type: CompositeRouteMatchType;
target_platform: ConcreteGroupPlatform;
upstream_model: string;
endpoint: CompositeRouteEndpoint;
priority: number;
enabled: boolean;
notes: string;
};
const showCompositeRoutesModal = ref(false);
const compositeRoutesGroup = ref<AdminGroup | null>(null);
const compositeRoutes = ref<CompositeModelRoute[]>([]);
const compositeRoutesLoading = ref(false);
const compositeRouteSaving = ref(false);
const compositeRouteEditingId = ref<number | null>(null);
const compositePreviewModel = ref("");
const compositePreviewEndpoint = ref<CompositeRouteEndpoint>("any");
const compositePreviewLoading = ref(false);
const compositePreviewDecision = ref<CompositeRouteDecision | null>(null);
const compositeRouteForm = reactive<CompositeRouteFormState>({
public_model: "",
match_type: "exact",
target_platform: "openai",
upstream_model: "",
endpoint: "any",
priority: 100,
enabled: true,
notes: "",
});
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const createModelsListState = reactive(createInitialModelsListState());
const editModelsListState = reactive(createInitialModelsListState());
const createModelsListLoading = ref(false);
const editModelsListLoading = ref(false);
type ReasoningEffortPolicyFieldsExpose = {
validate: () => boolean;
resetValidation: () => void;
};
const createReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
const editReasoningEffortPolicyRef = ref<ReasoningEffortPolicyFieldsExpose | null>(null);
const modelsListCandidatesTracker = createModelsListCandidatesTracker();
const createModelsListSelectedCount = computed(
() => createModelsListState.items.filter((item) => item.selected).length,
);
const editModelsListSelectedCount = computed(
() => editModelsListState.items.filter((item) => item.selected).length,
);
const createForm = reactive({
name: "",
description: "",
platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
subscription_type: "standard" as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: true,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
allow_batch_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
batch_image_discount_multiplier: 0.5,
batch_image_hold_multiplier: 0.6,
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
// 视频生成计费配置(仅 Grok 平台)
video_rate_independent: false,
video_rate_multiplier: 1,
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
search_price_per_1k: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
peak_end: "",
peak_rate_multiplier: 1.0,
// 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数
profit_control_enabled: false,
profit_min_margin_percent: 0,
profit_safety_buffer_percent: 0,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false,
allow_live: false,
opus_mapped_model: createMessagesDispatchDefaults.opus_mapped_model,
sonnet_mapped_model: createMessagesDispatchDefaults.sonnet_mapped_model,
haiku_mapped_model: createMessagesDispatchDefaults.haiku_mapped_model,
exact_model_mappings: [] as MessagesDispatchMappingRow[],
// 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only: false,
require_privacy_set: false,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ["claude", "gemini_text", "gemini_image"] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
// 简单账号类型(用于模型路由选择)
interface SimpleAccount {
id: number;
name: string;
}
// 模型路由规则类型
interface ModelRoutingRule {
pattern: string;
accounts: SimpleAccount[]; // 选中的账号对象数组
}
// 创建表单的模型路由规则
const createModelRoutingRules = ref<ModelRoutingRule[]>([]);
// 编辑表单的模型路由规则
const editModelRoutingRules = ref<ModelRoutingRule[]>([]);
// 规则对象稳定 key(避免使用 index 导致状态错位)
const resolveCreateRuleKey =
createStableObjectKeyResolver<ModelRoutingRule>("create-rule");
const resolveEditRuleKey =
createStableObjectKeyResolver<ModelRoutingRule>("edit-rule");
const resolveCreateMessagesDispatchRowKey =
createStableObjectKeyResolver<MessagesDispatchMappingRow>(
"create-messages-dispatch-row",
);
const resolveEditMessagesDispatchRowKey =
createStableObjectKeyResolver<MessagesDispatchMappingRow>(
"edit-messages-dispatch-row",
);
const getCreateRuleRenderKey = (rule: ModelRoutingRule) =>
resolveCreateRuleKey(rule);
const getEditRuleRenderKey = (rule: ModelRoutingRule) =>
resolveEditRuleKey(rule);
const getCreateMessagesDispatchRowKey = (row: MessagesDispatchMappingRow) =>
resolveCreateMessagesDispatchRowKey(row);
const getEditMessagesDispatchRowKey = (row: MessagesDispatchMappingRow) =>
resolveEditMessagesDispatchRowKey(row);
const getCreateRuleSearchKey = (rule: ModelRoutingRule) =>
`create-${resolveCreateRuleKey(rule)}`;
const getEditRuleSearchKey = (rule: ModelRoutingRule) =>
`edit-${resolveEditRuleKey(rule)}`;
const getRuleSearchKey = (rule: ModelRoutingRule, isEdit: boolean = false) => {
return isEdit ? getEditRuleSearchKey(rule) : getCreateRuleSearchKey(rule);
};
// 账号搜索相关状态
const accountSearchKeyword = ref<Record<string, string>>({});
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({});
const showAccountDropdown = ref<Record<string, boolean>>({});
const clearAccountSearchStateByKey = (key: string) => {
delete accountSearchKeyword.value[key];
delete accountSearchResults.value[key];
delete showAccountDropdown.value[key];
};
const clearAllAccountSearchState = () => {
accountSearchKeyword.value = {};
accountSearchResults.value = {};
showAccountDropdown.value = {};
};
const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
delay: 300,
search: async (keyword, { signal }) => {
const res = await adminAPI.accounts.list(
1,
20,
{
search: keyword,
platform: "anthropic",
},
{ signal },
);
return res.items.map((account) => ({ id: account.id, name: account.name }));
},
onSuccess: (key, result) => {
accountSearchResults.value[key] = result;
},
onError: (key) => {
accountSearchResults.value[key] = [];
},
});
// 搜索账号(仅限 anthropic 平台)
const searchAccounts = (key: string) => {
accountSearchRunner.trigger(key, accountSearchKeyword.value[key] || "");
};
const searchAccountsByRule = (
rule: ModelRoutingRule,
isEdit: boolean = false,
) => {
searchAccounts(getRuleSearchKey(rule, isEdit));
};
// 选择账号
const selectAccount = (
rule: ModelRoutingRule,
account: SimpleAccount,
isEdit: boolean = false,
) => {
if (!rule) return;
// 检查是否已选择
if (!rule.accounts.some((a) => a.id === account.id)) {
rule.accounts.push(account);
}
// 清空搜索
const key = getRuleSearchKey(rule, isEdit);
accountSearchKeyword.value[key] = "";
showAccountDropdown.value[key] = false;
};
// 移除已选账号
const removeSelectedAccount = (
rule: ModelRoutingRule,
accountId: number,
_isEdit: boolean = false,
) => {
if (!rule) return;
rule.accounts = rule.accounts.filter((a) => a.id !== accountId);
};
// 切换创建表单的模型系列选择
const toggleCreateScope = (scope: string) => {
const idx = createForm.supported_model_scopes.indexOf(scope);
if (idx === -1) {
createForm.supported_model_scopes.push(scope);
} else {
createForm.supported_model_scopes.splice(idx, 1);
}
};
// 切换编辑表单的模型系列选择
const toggleEditScope = (scope: string) => {
const idx = editForm.supported_model_scopes.indexOf(scope);
if (idx === -1) {
editForm.supported_model_scopes.push(scope);
} else {
editForm.supported_model_scopes.splice(idx, 1);
}
};
// 处理账号搜索输入框聚焦
const onAccountSearchFocus = (
rule: ModelRoutingRule,
isEdit: boolean = false,
) => {
const key = getRuleSearchKey(rule, isEdit);
showAccountDropdown.value[key] = true;
// 如果没有搜索结果,触发一次搜索
if (!accountSearchResults.value[key]?.length) {
searchAccounts(key);
}
};
// 添加创建表单的路由规则
const addCreateRoutingRule = () => {
createModelRoutingRules.value.push({ pattern: "", accounts: [] });
};
// 删除创建表单的路由规则
const removeCreateRoutingRule = (rule: ModelRoutingRule) => {
const index = createModelRoutingRules.value.indexOf(rule);
if (index === -1) return;
const key = getCreateRuleSearchKey(rule);
accountSearchRunner.clearKey(key);
clearAccountSearchStateByKey(key);
createModelRoutingRules.value.splice(index, 1);
};
// 添加编辑表单的路由规则
const addEditRoutingRule = () => {
editModelRoutingRules.value.push({ pattern: "", accounts: [] });
};
// 删除编辑表单的路由规则
const removeEditRoutingRule = (rule: ModelRoutingRule) => {
const index = editModelRoutingRules.value.indexOf(rule);
if (index === -1) return;
const key = getEditRuleSearchKey(rule);
accountSearchRunner.clearKey(key);
clearAccountSearchStateByKey(key);
editModelRoutingRules.value.splice(index, 1);
};
const resetModelsListState = (
state: typeof createModelsListState,
config?: Parameters<typeof createInitialModelsListState>[0],
) => {
const fresh = createInitialModelsListState(config);
state.enabled = fresh.enabled;
state.savedModels = fresh.savedModels;
state.items = fresh.items;
};
const loadModelsListCandidates = async (
mode: "create" | "edit",
groupID: number,
platform: GroupPlatform,
) => {
const request = { mode, groupID, platform };
const requestID = modelsListCandidatesTracker.next(request);
const state = mode === "create" ? createModelsListState : editModelsListState;
const loadingRef = mode === "create" ? createModelsListLoading : editModelsListLoading;
loadingRef.value = true;
try {
const models = await adminAPI.groups.getModelsListCandidates(groupID, platform);
if (!modelsListCandidatesTracker.isCurrent(requestID, request)) {
return;
}
setModelsListCandidates(state, models);
} catch (error) {
if (!modelsListCandidatesTracker.isCurrent(requestID, request)) {
return;
}
console.error("Error loading group models list candidates:", error);
} finally {
if (modelsListCandidatesTracker.isCurrent(requestID, request)) {
loadingRef.value = false;
}
}
};
const moveCreateModelsListItem = (fromIndex: number, toIndex: number) => {
moveModelsListItem(createModelsListState, fromIndex, toIndex);
};
const moveEditModelsListItem = (fromIndex: number, toIndex: number) => {
moveModelsListItem(editModelsListState, fromIndex, toIndex);
};
// 将 UI 格式的路由规则转换为 API 格式
const convertRoutingRulesToApiFormat = (
rules: ModelRoutingRule[],
): Record<string, number[]> | null => {
const result: Record<string, number[]> = {};
let hasValidRules = false;
for (const rule of rules) {
const pattern = rule.pattern.trim();
if (!pattern) continue;
const accountIds = rule.accounts.map((a) => a.id).filter((id) => id > 0);
if (accountIds.length > 0) {
result[pattern] = accountIds;
hasValidRules = true;
}
}
return hasValidRules ? result : null;
};
// 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称)
const convertApiFormatToRoutingRules = async (
apiFormat: Record<string, number[]> | null,
): Promise<ModelRoutingRule[]> => {
if (!apiFormat) return [];
const rules: ModelRoutingRule[] = [];
for (const [pattern, accountIds] of Object.entries(apiFormat)) {
// 加载账号信息
const accounts: SimpleAccount[] = [];
for (const id of accountIds) {
try {
const account = await adminAPI.accounts.getById(id);
accounts.push({ id: account.id, name: account.name });
} catch {
// 如果账号不存在,仍然显示 ID
accounts.push({ id, name: `#${id}` });
}
}
rules.push({ pattern, accounts });
}
return rules;
};
const editForm = reactive({
name: "",
description: "",
platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
status: "active" as "active" | "inactive",
subscription_type: "standard" as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: true,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
allow_batch_image_generation: false,
image_rate_independent: false,
image_rate_multiplier: 1,
batch_image_discount_multiplier: 0.5,
batch_image_hold_multiplier: 0.6,
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null,
// 视频生成计费配置(仅 Grok 平台)
video_rate_independent: false,
video_rate_multiplier: 1,
video_price_480p: null as number | null,
video_price_720p: null as number | null,
video_price_1080p: null as number | null,
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
search_price_per_1k: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
peak_end: "",
peak_rate_multiplier: 1.0,
// 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数
profit_control_enabled: false,
profit_min_margin_percent: 0,
profit_safety_buffer_percent: 0,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false,
allow_live: false,
default_mapped_model: '',
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model,
exact_model_mappings: [] as MessagesDispatchMappingRow[],
// 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only: false,
require_privacy_set: false,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ["claude", "gemini_text", "gemini_image"] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
max_reasoning_effort: "",
reasoning_effort_mappings: [] as ReasoningEffortMappingRow[],
});
type ImagePricingFormState = {
platform: GroupPlatform;
allow_image_generation: boolean;
allow_batch_image_generation: boolean;
rate_multiplier: number;
image_rate_independent: boolean;
image_rate_multiplier: number;
batch_image_discount_multiplier: number;
batch_image_hold_multiplier: number;
image_price_1k: number | string | null;
image_price_2k: number | string | null;
image_price_4k: number | string | null;
peak_rate_enabled: boolean;
peak_start: string;
peak_end: string;
peak_rate_multiplier: number;
};
type VideoPricingFormState = {
platform: GroupPlatform;
rate_multiplier: number;
video_rate_independent: boolean;
video_rate_multiplier: number;
video_price_480p: number | string | null;
video_price_720p: number | string | null;
video_price_1080p: number | string | null;
};
const imagePricingTiers = [
{ key: "image_price_1k", label: "1K" },
{ key: "image_price_2k", label: "2K" },
{ key: "image_price_4k", label: "4K" },
] as const;
const videoPricingTiers = [
{ key: "video_price_480p", label: "480p" },
{ key: "video_price_720p", label: "720p" },
{ key: "video_price_1080p", label: "1080p" },
] as const;
const normalizePreviewNumber = (value: number | string | null | undefined, fallback = 0) => {
if (value === null || value === undefined || value === "") {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const parsePreviewPrice = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const formatImagePricePreview = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === "") {
return t("admin.groups.imagePricing.notConfigured");
}
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return t("admin.groups.imagePricing.notConfigured");
}
return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
};
const formatVideoPricePreview = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === "") {
return t("admin.groups.videoPricing.notConfigured");
}
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return t("admin.groups.videoPricing.notConfigured");
}
return `$${price.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
};
const buildImageFinalPricePreview = (form: ImagePricingFormState) => {
const imageMultiplier = form.image_rate_independent
? normalizePreviewNumber(form.image_rate_multiplier, 1)
: normalizePreviewNumber(form.rate_multiplier, 1);
const multiplier = imageMultiplier;
return imagePricingTiers.map((tier) => {
const basePrice =
parsePreviewPrice(form[tier.key]) ??
getDefaultImagePreviewPrice(form.platform, tier.key);
return {
label: tier.label,
value: basePrice !== null
? formatImagePricePreview(basePrice * multiplier)
: t("admin.groups.imagePricing.notConfigured"),
};
});
};
const buildVideoFinalPricePreview = (form: VideoPricingFormState) => {
const multiplier = form.video_rate_independent
? normalizePreviewNumber(form.video_rate_multiplier, 1)
: normalizePreviewNumber(form.rate_multiplier, 1);
return videoPricingTiers.map((tier) => {
const basePrice =
parsePreviewPrice(form[tier.key]) ??
getDefaultVideoPreviewPrice(form.platform, tier.key);
return {
label: tier.label,
value: basePrice !== null
? formatVideoPricePreview(basePrice * multiplier)
: t("admin.groups.videoPricing.notConfigured"),
};
});
};
const createImageFinalPricePreview = computed(() =>
buildImageFinalPricePreview(createForm),
);
const editImageFinalPricePreview = computed(() =>
buildImageFinalPricePreview(editForm),
);
const createVideoFinalPricePreview = computed(() =>
buildVideoFinalPricePreview(createForm),
);
const editVideoFinalPricePreview = computed(() =>
buildVideoFinalPricePreview(editForm),
);
// Codex 网页搜索单次默认价(与后端 defaultWebSearchPricePerCall 一致,官方 $10/1000 次)
const DEFAULT_WEB_SEARCH_PRICE_PER_CALL = 0.01;
const buildWebSearchFinalPricePreview = (form: {
web_search_price_per_call: number | string | null;
rate_multiplier: number | string | null;
}) => {
const basePrice =
parsePreviewPrice(form.web_search_price_per_call) ??
DEFAULT_WEB_SEARCH_PRICE_PER_CALL;
const multiplier = normalizePreviewNumber(form.rate_multiplier, 1);
return formatImagePricePreview(basePrice * multiplier);
};
const createWebSearchFinalPricePreview = computed(() =>
buildWebSearchFinalPricePreview(createForm),
);
const editWebSearchFinalPricePreview = computed(() =>
buildWebSearchFinalPricePreview(editForm),
);
const resetDisabledBatchImagePricing = (
form: Pick<
ImagePricingFormState,
"platform" | "allow_image_generation" | "allow_batch_image_generation" | "batch_image_discount_multiplier" | "batch_image_hold_multiplier"
>,
) => {
if (form.platform !== "gemini" || !form.allow_image_generation) {
form.allow_batch_image_generation = false;
}
if (!form.allow_batch_image_generation) {
form.batch_image_discount_multiplier = 0.5;
form.batch_image_hold_multiplier = 0.6;
}
};
// 根据分组类型返回不同的删除确认消息
const deleteConfirmMessage = computed(() => {
if (!deletingGroup.value) {
return "";
}
if (deletingGroup.value.subscription_type === "subscription") {
return t("admin.groups.deleteConfirmSubscription", {
name: deletingGroup.value.name,
});
}
return t("admin.groups.deleteConfirm", { name: deletingGroup.value.name });
});
const loadLiveCapability = async () => {
if (liveCapability.value) return liveCapability.value;
if (!liveCapabilityRequest) {
liveCapabilityRequest = adminAPI.groups
.getLiveCapability()
.catch(() => ({ supported: false }))
.finally(() => {
liveCapabilityRequest = null;
});
}
liveCapability.value = await liveCapabilityRequest;
return liveCapability.value ?? { supported: false };
};
const toggleLive = async (target: "create" | "edit") => {
const form = target === "create" ? createForm : editForm;
if (form.allow_live) {
form.allow_live = false;
return;
}
const capability = await loadLiveCapability();
if (capability.supported) {
form.allow_live = true;
return;
}
pendingLiveForm.value = target;
};
const confirmUnsupportedLive = () => {
if (pendingLiveForm.value === "create") createForm.allow_live = true;
if (pendingLiveForm.value === "edit") editForm.allow_live = true;
pendingLiveForm.value = null;
};
const cancelUnsupportedLive = () => {
pendingLiveForm.value = null;
};
const loadGroups = async () => {
if (abortController) {
abortController.abort();
}
const currentController = new AbortController();
abortController = currentController;
const { signal } = currentController;
loading.value = true;
try {
const response = await adminAPI.groups.list(
pagination.page,
pagination.page_size,
{
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive
? filters.is_exclusive === "true"
: undefined,
search: searchQuery.value.trim() || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order,
},
{ signal },
);
if (signal.aborted) return;
groups.value = response.items;
pagination.total = response.total;
pagination.pages = response.pages;
if (hasVisibleUsageSummaryConsumer.value) {
loadUsageSummary();
} else {
usageLoading.value = false;
}
if (hasVisibleCapacityColumn.value) {
loadCapacitySummary();
}
} catch (error: any) {
if (
signal.aborted ||
error?.name === "AbortError" ||
error?.code === "ERR_CANCELED"
) {
return;
}
appStore.showError(t("admin.groups.failedToLoad"));
console.error("Error loading groups:", error);
} finally {
if (abortController === currentController && !signal.aborted) {
loading.value = false;
}
}
};
const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0);
if (cost >= 100) return cost.toFixed(1);
return cost.toFixed(2);
};
const formatUsd = (cost: number | null | undefined): string =>
`$${formatCost(cost ?? 0)}`;
const getQuotaUsageClass = (
used: number,
limit: number | null | undefined,
): string => {
if (!limit || limit <= 0) {
return "font-medium text-gray-700 dark:text-gray-300";
}
const ratio = used / limit;
if (ratio >= 1) {
return "font-semibold text-red-600 dark:text-red-400";
}
if (ratio >= 0.8) {
return "font-semibold text-amber-600 dark:text-amber-400";
}
return "font-medium text-gray-700 dark:text-gray-300";
};
const loadUsageSummary = async () => {
if (!hasVisibleUsageSummaryConsumer.value) {
usageLoading.value = false;
return;
}
usageLoading.value = true;
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const data = await adminAPI.groups.getUsageSummary(tz);
const map = new Map<number, GroupUsageSummary>();
for (const item of data) {
map.set(item.group_id, {
today_cost: item.today_cost,
total_cost: item.total_cost,
});
}
usageMap.value = map;
} catch (error) {
console.error("Error loading group usage summary:", error);
} finally {
usageLoading.value = false;
}
};
const loadCapacitySummary = async () => {
if (!hasVisibleCapacityColumn.value) {
return;
}
try {
const data = await adminAPI.groups.getCapacitySummary();
const map = new Map<
number,
{
concurrencyUsed: number;
concurrencyMax: number;
sessionsUsed: number;
sessionsMax: number;
rpmUsed: number;
rpmMax: number;
}
>();
for (const item of data) {
map.set(item.group_id, {
concurrencyUsed: item.concurrency_used,
concurrencyMax: item.concurrency_max,
sessionsUsed: item.sessions_used,
sessionsMax: item.sessions_max,
rpmUsed: item.rpm_used,
rpmMax: item.rpm_max,
});
}
capacityMap.value = map;
} catch (error) {
console.error("Error loading group capacity summary:", error);
}
};
let searchTimeout: ReturnType<typeof setTimeout>;
const handleSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
pagination.page = 1;
loadGroups();
}, 300);
};
const handlePageChange = (page: number) => {
pagination.page = page;
loadGroups();
};
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize;
pagination.page = 1;
loadGroups();
};
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key;
sortState.sort_order = order;
pagination.page = 1;
loadGroups();
};
const openCreateModal = () => {
showCreateModal.value = true;
loadModelsListCandidates("create", 0, createForm.platform);
};
const closeCreateModal = () => {
showCreateModal.value = false;
createModelRoutingRules.value.forEach((rule) => {
accountSearchRunner.clearKey(getCreateRuleSearchKey(rule));
});
clearAllAccountSearchState();
createForm.name = "";
createForm.description = "";
createForm.platform = "anthropic";
createForm.rate_multiplier = 1.0;
createForm.is_exclusive = false;
createForm.subscription_type = "standard";
createForm.daily_limit_usd = null;
createForm.weekly_limit_usd = null;
createForm.monthly_limit_usd = null;
createForm.allow_image_generation = false;
createForm.allow_batch_image_generation = false;
createForm.image_rate_independent = false;
createForm.image_rate_multiplier = 1;
createForm.batch_image_discount_multiplier = 0.5;
createForm.batch_image_hold_multiplier = 0.6;
createForm.image_price_1k = null;
createForm.image_price_2k = null;
createForm.image_price_4k = null;
createForm.video_rate_independent = false;
createForm.video_rate_multiplier = 1;
createForm.video_price_480p = null;
createForm.video_price_720p = null;
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.long_context_pricing_enabled = true;
createForm.model_pricing = [];
createForm.web_search_price_per_call = null;
createForm.search_price_per_1k = null;
createForm.audio_realtime_price_per_min = null;
createForm.audio_tts_price_per_million_chars = null;
createForm.audio_stt_price_per_hour = null;
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
createForm.peak_rate_multiplier = 1.0;
createForm.profit_control_enabled = false;
createForm.profit_min_margin_percent = 0;
createForm.profit_safety_buffer_percent = 0;
createForm.claude_code_only = false;
createForm.fallback_group_id = null;
createForm.fallback_group_id_on_invalid_request = null;
resetMessagesDispatchFormState(createForm);
createForm.allow_live = false;
createForm.require_oauth_only = false;
createForm.require_privacy_set = false;
createForm.supported_model_scopes = ["claude", "gemini_text", "gemini_image"];
createForm.mcp_xml_inject = true;
createForm.copy_accounts_from_group_ids = [];
createForm.rpm_limit = 0;
createForm.max_reasoning_effort = "";
createForm.reasoning_effort_mappings = [];
createReasoningEffortPolicyRef.value?.resetValidation();
resetModelsListState(createModelsListState);
createModelRoutingRules.value = [];
};
const normalizeOptionalLimit = (
value: number | string | null | undefined,
): number | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return Number.isFinite(value) && value > 0 ? value : null;
};
const normalizeRateMultiplier = (
value: number | string | null | undefined,
): number => {
if (value === null || value === undefined || value === "") {
return 1;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1;
};
// 利润控制表单辅助(换算与校验逻辑见 groupsProfitControl.ts,便于单测)。
const percentToDecimal = profitPercentToDecimal;
const decimalToPercent = profitDecimalToPercent;
const validateProfitControlForm = (form: ProfitControlFormState): boolean => {
const errorKey = validateProfitControlFormState(form);
if (errorKey) {
appStore.showError(t(`admin.groups.profitControl.${errorKey}`));
return false;
}
return true;
};
const handleCreateGroup = async () => {
if (!createForm.name.trim()) {
appStore.showError(t("admin.groups.nameRequired"));
return;
}
if (
supportsReasoningEffortPolicyPlatform(createForm.platform) &&
createReasoningEffortPolicyRef.value &&
!createReasoningEffortPolicyRef.value.validate()
) {
return;
}
if (!validateProfitControlForm(createForm)) {
return;
}
submitting.value = true;
try {
const {
video_model_prices: _createFormVideoModelPrices,
...createGroupForm
} = createForm;
const videoModelPrices = serializeVideoModelPrices(
createForm.video_model_prices,
);
// 构建请求数据,包含模型路由配置
const requestData = {
...createGroupForm,
model_pricing: groupPricingToAPI(
createForm.model_pricing,
createForm.platform,
),
daily_limit_usd: normalizeOptionalLimit(
createForm.daily_limit_usd as number | string | null,
),
weekly_limit_usd: normalizeOptionalLimit(
createForm.weekly_limit_usd as number | string | null,
),
monthly_limit_usd: normalizeOptionalLimit(
createForm.monthly_limit_usd as number | string | null,
),
...(Object.keys(videoModelPrices).length > 0
? { video_model_prices: videoModelPrices }
: {}),
model_routing: convertRoutingRulesToApiFormat(
createModelRoutingRules.value,
),
models_list_config: buildModelsListConfig(createModelsListState),
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
createForm.platform,
createForm.supported_model_scopes,
),
messages_dispatch_model_config:
createForm.platform === "openai"
? messagesDispatchFormStateToConfig({
allow_messages_dispatch: createForm.allow_messages_dispatch,
opus_mapped_model: createForm.opus_mapped_model,
sonnet_mapped_model: createForm.sonnet_mapped_model,
haiku_mapped_model: createForm.haiku_mapped_model,
exact_model_mappings: createForm.exact_model_mappings,
})
: undefined,
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
createForm.reasoning_effort_mappings,
),
// 利润控制:界面百分比转小数提交;仅五个 token 平台可启用
profit_control_enabled:
isProfitControlPlatform(createForm.platform) &&
createForm.profit_control_enabled,
profit_min_margin: percentToDecimal(createForm.profit_min_margin_percent),
profit_safety_buffer: percentToDecimal(
createForm.profit_safety_buffer_percent,
),
};
delete (requestData as Record<string, unknown>).profit_min_margin_percent;
delete (requestData as Record<string, unknown>).profit_safety_buffer_percent;
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => (v === "" ? null : v);
requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd);
requestData.weekly_limit_usd = emptyToNull(requestData.weekly_limit_usd);
requestData.monthly_limit_usd = emptyToNull(requestData.monthly_limit_usd);
requestData.image_rate_multiplier = normalizeRateMultiplier(
requestData.image_rate_multiplier,
);
resetDisabledBatchImagePricing(requestData);
requestData.batch_image_discount_multiplier = normalizeRateMultiplier(
requestData.batch_image_discount_multiplier,
);
requestData.batch_image_hold_multiplier = normalizeRateMultiplier(
requestData.batch_image_hold_multiplier,
);
requestData.video_rate_multiplier = normalizeRateMultiplier(
requestData.video_rate_multiplier,
);
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400),
// 创建时按"未配置"(null)处理。
requestData.image_price_1k = emptyToNull(requestData.image_price_1k);
requestData.image_price_2k = emptyToNull(requestData.image_price_2k);
requestData.image_price_4k = emptyToNull(requestData.image_price_4k);
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
requestData.search_price_per_1k = emptyToNull(
requestData.search_price_per_1k,
);
requestData.audio_realtime_price_per_min = emptyToNull(
requestData.audio_realtime_price_per_min,
);
requestData.audio_tts_price_per_million_chars = emptyToNull(
requestData.audio_tts_price_per_million_chars,
);
requestData.audio_stt_price_per_hour = emptyToNull(
requestData.audio_stt_price_per_hour,
);
requestData.web_search_price_per_call = emptyToNull(
requestData.web_search_price_per_call,
);
requestData.peak_rate_enabled = createForm.peak_rate_enabled;
requestData.peak_start = createForm.peak_start;
requestData.peak_end = createForm.peak_end;
requestData.peak_rate_multiplier = normalizeRateMultiplier(
createForm.peak_rate_multiplier,
);
await adminAPI.groups.create(requestData);
appStore.showSuccess(t("admin.groups.groupCreated"));
closeCreateModal();
loadGroups();
// Only advance tour if active, on submit step, and creation succeeded
if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) {
onboardingStore.nextStep(500);
}
} catch (error: any) {
appStore.showError(
error.response?.data?.detail || t("admin.groups.failedToCreate"),
);
console.error("Error creating group:", error);
// Don't advance tour on error
} finally {
submitting.value = false;
}
};
const handleEdit = async (group: AdminGroup) => {
editingGroup.value = group;
editForm.name = group.name;
editForm.description = group.description || "";
editForm.platform = group.platform;
editForm.rate_multiplier = group.rate_multiplier;
editForm.is_exclusive = group.is_exclusive;
editForm.status = group.status;
editForm.subscription_type = group.subscription_type || "standard";
editForm.daily_limit_usd = group.daily_limit_usd;
editForm.weekly_limit_usd = group.weekly_limit_usd;
editForm.monthly_limit_usd = group.monthly_limit_usd;
editForm.long_context_pricing_enabled =
group.long_context_pricing_enabled ?? true;
editForm.model_pricing = groupPricingFromAPI(group.model_pricing);
editForm.allow_image_generation = group.allow_image_generation ?? false;
editForm.allow_batch_image_generation =
group.allow_batch_image_generation ?? false;
editForm.image_rate_independent = group.image_rate_independent ?? false;
editForm.image_rate_multiplier = group.image_rate_multiplier ?? 1;
editForm.batch_image_discount_multiplier =
group.batch_image_discount_multiplier ?? 0.5;
editForm.batch_image_hold_multiplier = group.batch_image_hold_multiplier ?? 0.6;
editForm.image_price_1k = group.image_price_1k;
editForm.image_price_2k = group.image_price_2k;
editForm.image_price_4k = group.image_price_4k;
editForm.video_rate_independent = group.video_rate_independent ?? false;
editForm.video_rate_multiplier = group.video_rate_multiplier ?? 1;
editForm.video_price_480p = group.video_price_480p;
editForm.video_price_720p = group.video_price_720p;
editForm.video_price_1080p = group.video_price_1080p;
editForm.video_model_prices = createVideoModelPricesForm(
group.video_model_prices,
);
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
editForm.search_price_per_1k = group.search_price_per_1k ?? null;
editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null;
editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null;
editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null;
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
editForm.peak_start = group.peak_start ?? "";
editForm.peak_end = group.peak_end ?? "";
editForm.peak_rate_multiplier = group.peak_rate_multiplier ?? 1.0;
editForm.profit_control_enabled = group.profit_control_enabled ?? false;
editForm.profit_min_margin_percent = decimalToPercent(
group.profit_min_margin ?? 0,
);
editForm.profit_safety_buffer_percent = decimalToPercent(
group.profit_safety_buffer ?? 0,
);
editForm.claude_code_only = group.claude_code_only || false;
editForm.fallback_group_id = group.fallback_group_id;
editForm.fallback_group_id_on_invalid_request =
group.fallback_group_id_on_invalid_request;
const messagesDispatchFormState = messagesDispatchConfigToFormState(
group.messages_dispatch_model_config,
);
editForm.allow_messages_dispatch =
group.allow_messages_dispatch ||
messagesDispatchFormState.allow_messages_dispatch;
editForm.allow_live = group.allow_live ?? false;
editForm.opus_mapped_model = messagesDispatchFormState.opus_mapped_model;
editForm.sonnet_mapped_model = messagesDispatchFormState.sonnet_mapped_model;
editForm.haiku_mapped_model = messagesDispatchFormState.haiku_mapped_model;
editForm.exact_model_mappings =
messagesDispatchFormState.exact_model_mappings;
editForm.require_oauth_only = group.require_oauth_only ?? false;
editForm.require_privacy_set = group.require_privacy_set ?? false;
editForm.model_routing_enabled = group.model_routing_enabled || false;
editForm.supported_model_scopes = group.supported_model_scopes || [
"claude",
"gemini_text",
"gemini_image",
];
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
editForm.rpm_limit = group.rpm_limit ?? 0;
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
group.platform,
group.max_reasoning_effort,
);
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
group.reasoning_effort_mappings,
group.platform,
);
resetModelsListState(editModelsListState, group.models_list_config);
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
group.model_routing,
);
loadModelsListCandidates("edit", group.id, group.platform);
showEditModal.value = true;
};
const closeEditModal = () => {
editModelRoutingRules.value.forEach((rule) => {
accountSearchRunner.clearKey(getEditRuleSearchKey(rule));
});
clearAllAccountSearchState();
showEditModal.value = false;
editingGroup.value = null;
editForm.max_reasoning_effort = "";
editForm.reasoning_effort_mappings = [];
editReasoningEffortPolicyRef.value?.resetValidation();
editModelRoutingRules.value = [];
editForm.copy_accounts_from_group_ids = [];
editForm.peak_rate_enabled = false;
editForm.peak_start = "";
editForm.peak_end = "";
editForm.peak_rate_multiplier = 1.0;
editForm.profit_control_enabled = false;
editForm.profit_min_margin_percent = 0;
editForm.profit_safety_buffer_percent = 0;
editForm.video_rate_independent = false;
editForm.video_rate_multiplier = 1;
editForm.video_price_480p = null;
editForm.video_price_720p = null;
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.long_context_pricing_enabled = true;
editForm.model_pricing = [];
editForm.web_search_price_per_call = null;
editForm.search_price_per_1k = null;
editForm.audio_realtime_price_per_min = null;
editForm.audio_tts_price_per_million_chars = null;
editForm.audio_stt_price_per_hour = null;
resetMessagesDispatchFormState(editForm);
editForm.allow_live = false;
resetModelsListState(editModelsListState);
};
const handleUpdateGroup = async () => {
if (!editingGroup.value) return;
if (!editForm.name.trim()) {
appStore.showError(t("admin.groups.nameRequired"));
return;
}
if (
supportsReasoningEffortPolicyPlatform(editForm.platform) &&
editReasoningEffortPolicyRef.value &&
!editReasoningEffortPolicyRef.value.validate()
) {
return;
}
if (!validateProfitControlForm(editForm)) {
return;
}
submitting.value = true;
try {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
model_pricing: groupPricingToAPI(
editForm.model_pricing,
editForm.platform,
),
daily_limit_usd: normalizeOptionalLimit(
editForm.daily_limit_usd as number | string | null,
),
weekly_limit_usd: normalizeOptionalLimit(
editForm.weekly_limit_usd as number | string | null,
),
monthly_limit_usd: normalizeOptionalLimit(
editForm.monthly_limit_usd as number | string | null,
),
video_model_prices: serializeVideoModelPrices(
editForm.video_model_prices,
),
fallback_group_id:
editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
? 0
: editForm.fallback_group_id_on_invalid_request,
model_routing: convertRoutingRulesToApiFormat(
editModelRoutingRules.value,
),
models_list_config: buildModelsListConfig(editModelsListState),
supported_model_scopes: normalizeSupportedModelScopesForPlatform(
editForm.platform,
editForm.supported_model_scopes,
),
messages_dispatch_model_config:
editForm.platform === "openai"
? messagesDispatchFormStateToConfig({
allow_messages_dispatch: editForm.allow_messages_dispatch,
opus_mapped_model: editForm.opus_mapped_model,
sonnet_mapped_model: editForm.sonnet_mapped_model,
haiku_mapped_model: editForm.haiku_mapped_model,
exact_model_mappings: editForm.exact_model_mappings,
})
: undefined,
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
editForm.reasoning_effort_mappings,
),
// 利润控制:界面百分比转小数提交;仅五个 token 平台可启用
profit_control_enabled:
isProfitControlPlatform(editForm.platform) &&
editForm.profit_control_enabled,
profit_min_margin: percentToDecimal(editForm.profit_min_margin_percent),
profit_safety_buffer: percentToDecimal(
editForm.profit_safety_buffer_percent,
),
};
delete (payload as Record<string, unknown>).profit_min_margin_percent;
delete (payload as Record<string, unknown>).profit_safety_buffer_percent;
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => (v === "" ? null : v);
payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd);
payload.weekly_limit_usd = emptyToNull(payload.weekly_limit_usd);
payload.monthly_limit_usd = emptyToNull(payload.monthly_limit_usd);
payload.image_rate_multiplier = normalizeRateMultiplier(
payload.image_rate_multiplier,
);
resetDisabledBatchImagePricing(payload);
payload.batch_image_discount_multiplier = normalizeRateMultiplier(
payload.batch_image_discount_multiplier,
);
payload.batch_image_hold_multiplier = normalizeRateMultiplier(
payload.batch_image_hold_multiplier,
);
payload.video_rate_multiplier = normalizeRateMultiplier(
payload.video_rate_multiplier,
);
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400)。
// 更新语义中 null 表示"不修改",因此清空后的字段发送 -1:后端 normalizePrice 将负价归一为
// NULL,从而真正清除已配置的价格。
const emptyPriceToClear = (v: any) => (v === "" || v === null ? -1 : v);
payload.image_price_1k = emptyPriceToClear(payload.image_price_1k);
payload.image_price_2k = emptyPriceToClear(payload.image_price_2k);
payload.image_price_4k = emptyPriceToClear(payload.image_price_4k);
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
payload.search_price_per_1k = emptyPriceToClear(
payload.search_price_per_1k,
);
payload.audio_realtime_price_per_min = emptyPriceToClear(
payload.audio_realtime_price_per_min,
);
payload.audio_tts_price_per_million_chars = emptyPriceToClear(
payload.audio_tts_price_per_million_chars,
);
payload.audio_stt_price_per_hour = emptyPriceToClear(
payload.audio_stt_price_per_hour,
);
payload.web_search_price_per_call = emptyPriceToClear(
payload.web_search_price_per_call,
);
payload.peak_rate_enabled = editForm.peak_rate_enabled;
payload.peak_start = editForm.peak_start;
payload.peak_end = editForm.peak_end;
payload.peak_rate_multiplier = normalizeRateMultiplier(
editForm.peak_rate_multiplier,
);
await adminAPI.groups.update(editingGroup.value.id, payload);
appStore.showSuccess(t("admin.groups.groupUpdated"));
closeEditModal();
loadGroups();
} catch (error: any) {
appStore.showError(
error.response?.data?.detail || t("admin.groups.failedToUpdate"),
);
console.error("Error updating group:", error);
} finally {
submitting.value = false;
}
};
const addCreateMessagesDispatchMapping = () => {
createForm.exact_model_mappings.push({ claude_model: "", target_model: "" });
};
const removeCreateMessagesDispatchMapping = (
row: MessagesDispatchMappingRow,
) => {
const index = createForm.exact_model_mappings.indexOf(row);
if (index !== -1) {
createForm.exact_model_mappings.splice(index, 1);
}
};
const addEditMessagesDispatchMapping = () => {
editForm.exact_model_mappings.push({ claude_model: "", target_model: "" });
};
const removeEditMessagesDispatchMapping = (row: MessagesDispatchMappingRow) => {
const index = editForm.exact_model_mappings.indexOf(row);
if (index !== -1) {
editForm.exact_model_mappings.splice(index, 1);
}
};
const handleRateMultipliers = (group: AdminGroup) => {
rateMultipliersGroup.value = group;
showRateMultipliersModal.value = true;
};
const handleRPMOverrides = (group: AdminGroup) => {
rpmOverridesGroup.value = group;
showRPMOverridesModal.value = true;
};
const handleDuplicate = async (group: AdminGroup) => {
if (duplicatingGroupIds.has(group.id)) return;
duplicatingGroupIds.add(group.id);
try {
const duplicate = await adminAPI.groups.duplicate(group.id);
appStore.showSuccess(
t("admin.groups.duplicateSuccess", { name: duplicate.name }),
);
await loadGroups();
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.groups.duplicateFailed")),
);
} finally {
duplicatingGroupIds.delete(group.id);
}
};
const compositeRouteMatchLabel = (matchType: CompositeRouteMatchType) =>
compositeRouteMatchOptions.value.find((option) => option.value === matchType)
?.label || matchType;
const formatCompositeEndpoint = (endpoint: CompositeRouteEndpoint) =>
compositeRouteEndpointOptions.value.find((option) => option.value === endpoint)
?.label || endpoint;
const formatCompositePlatform = (platform: string) => {
if (!platform) return "—";
return t(`admin.groups.platforms.${platform}`);
};
const compositeRouteSourceLabel = (source: string) => {
if (source === "route") return t("admin.groups.compositeRoutes.sources.route");
if (source === "detector") {
return t("admin.groups.compositeRoutes.sources.detector");
}
return source || "—";
};
const resetCompositeRouteForm = () => {
compositeRouteEditingId.value = null;
compositeRouteForm.public_model = "";
compositeRouteForm.match_type = "exact";
compositeRouteForm.target_platform = "openai";
compositeRouteForm.upstream_model = "";
compositeRouteForm.endpoint = "any";
compositeRouteForm.priority = 100;
compositeRouteForm.enabled = true;
compositeRouteForm.notes = "";
};
const toCompositeRouteInput = (): CompositeModelRouteInput => ({
public_model: compositeRouteForm.public_model.trim(),
match_type: compositeRouteForm.match_type,
target_platform: compositeRouteForm.target_platform,
upstream_model: compositeRouteForm.upstream_model.trim(),
endpoint: compositeRouteForm.endpoint,
priority: Number(compositeRouteForm.priority) || 100,
enabled: compositeRouteForm.enabled,
notes: compositeRouteForm.notes.trim(),
});
const loadCompositeRoutes = async () => {
if (!compositeRoutesGroup.value) return;
compositeRoutesLoading.value = true;
try {
const routes = await adminAPI.groups.listCompositeRoutes(
compositeRoutesGroup.value.id,
);
compositeRoutes.value = routes.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.id - b.id;
});
} catch (error: any) {
appStore.showError(
error.response?.data?.detail ||
error.response?.data?.message ||
t("admin.groups.compositeRoutes.failedToLoad"),
);
console.error("Error loading composite routes:", error);
} finally {
compositeRoutesLoading.value = false;
}
};
const handleCompositeRoutes = async (group: AdminGroup) => {
compositeRoutesGroup.value = group;
compositePreviewModel.value = "";
compositePreviewEndpoint.value = "any";
compositePreviewDecision.value = null;
resetCompositeRouteForm();
showCompositeRoutesModal.value = true;
await loadCompositeRoutes();
};
const closeCompositeRoutesModal = () => {
showCompositeRoutesModal.value = false;
compositeRoutesGroup.value = null;
compositeRoutes.value = [];
compositePreviewDecision.value = null;
resetCompositeRouteForm();
};
const editCompositeRoute = (route: CompositeModelRoute) => {
compositeRouteEditingId.value = route.id;
compositeRouteForm.public_model = route.public_model;
compositeRouteForm.match_type = route.match_type;
compositeRouteForm.target_platform = route.target_platform;
compositeRouteForm.upstream_model = route.upstream_model;
compositeRouteForm.endpoint = route.endpoint;
compositeRouteForm.priority = route.priority || 100;
compositeRouteForm.enabled = route.enabled;
compositeRouteForm.notes = route.notes || "";
};
const saveCompositeRoute = async () => {
if (!compositeRoutesGroup.value) return;
if (!compositeRouteForm.public_model.trim()) {
appStore.showError(t("admin.groups.compositeRoutes.publicModelRequired"));
return;
}
compositeRouteSaving.value = true;
try {
const payload = toCompositeRouteInput();
if (compositeRouteEditingId.value) {
await adminAPI.groups.updateCompositeRoute(
compositeRoutesGroup.value.id,
compositeRouteEditingId.value,
payload,
);
appStore.showSuccess(t("admin.groups.compositeRoutes.routeUpdated"));
} else {
await adminAPI.groups.createCompositeRoute(
compositeRoutesGroup.value.id,
payload,
);
appStore.showSuccess(t("admin.groups.compositeRoutes.routeCreated"));
}
resetCompositeRouteForm();
await loadCompositeRoutes();
} catch (error: any) {
appStore.showError(
error.response?.data?.detail ||
error.response?.data?.message ||
t("admin.groups.compositeRoutes.failedToSave"),
);
console.error("Error saving composite route:", error);
} finally {
compositeRouteSaving.value = false;
}
};
const deleteCompositeRoute = async (route: CompositeModelRoute) => {
if (!compositeRoutesGroup.value) return;
if (!window.confirm(t("admin.groups.compositeRoutes.deleteConfirm"))) return;
try {
await adminAPI.groups.deleteCompositeRoute(
compositeRoutesGroup.value.id,
route.id,
);
if (compositeRouteEditingId.value === route.id) {
resetCompositeRouteForm();
}
appStore.showSuccess(t("admin.groups.compositeRoutes.routeDeleted"));
await loadCompositeRoutes();
} catch (error: any) {
appStore.showError(
error.response?.data?.detail ||
error.response?.data?.message ||
t("admin.groups.compositeRoutes.failedToDelete"),
);
console.error("Error deleting composite route:", error);
}
};
const previewCompositeRoute = async () => {
if (!compositeRoutesGroup.value || !compositePreviewModel.value.trim()) {
return;
}
compositePreviewLoading.value = true;
try {
compositePreviewDecision.value = await adminAPI.groups.previewCompositeRoute(
compositeRoutesGroup.value.id,
{
model: compositePreviewModel.value.trim(),
endpoint: compositePreviewEndpoint.value,
},
);
} catch (error: any) {
appStore.showError(
error.response?.data?.detail ||
error.response?.data?.message ||
t("admin.groups.compositeRoutes.failedToPreview"),
);
console.error("Error previewing composite route:", error);
} finally {
compositePreviewLoading.value = false;
}
};
const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group;
showDeleteDialog.value = true;
};
const confirmDelete = async () => {
if (!deletingGroup.value) return;
try {
await adminAPI.groups.delete(deletingGroup.value.id);
appStore.showSuccess(t("admin.groups.groupDeleted"));
showDeleteDialog.value = false;
deletingGroup.value = null;
loadGroups();
} catch (error: any) {
appStore.showError(
error.response?.data?.detail || t("admin.groups.failedToDelete"),
);
console.error("Error deleting group:", error);
}
};
// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true;标准模式清空高峰配置
watch(
() => createForm.subscription_type,
(newVal) => {
if (newVal === "subscription") {
createForm.is_exclusive = true;
createForm.fallback_group_id_on_invalid_request = null;
} else {
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
createForm.peak_rate_multiplier = 1.0;
}
},
);
// 编辑表单:切回标准模式时清空高峰配置,避免残留随更新请求提交被后端拒绝
watch(
() => editForm.subscription_type,
(newVal) => {
if (newVal !== "subscription") {
editForm.peak_rate_enabled = false;
editForm.peak_start = "";
editForm.peak_end = "";
editForm.peak_rate_multiplier = 1.0;
}
},
);
watch(
() => createForm.platform,
(newVal) => {
if (!["anthropic", "antigravity"].includes(newVal)) {
createForm.fallback_group_id_on_invalid_request = null;
}
if (newVal !== "openai") {
resetMessagesDispatchFormState(createForm);
createForm.allow_live = false;
}
if (!isProfitControlPlatform(newVal)) {
createForm.profit_control_enabled = false;
createForm.profit_min_margin_percent = 0;
createForm.profit_safety_buffer_percent = 0;
}
createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
newVal,
createForm.max_reasoning_effort,
);
createForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(createForm.reasoning_effort_mappings),
newVal,
);
createReasoningEffortPolicyRef.value?.resetValidation();
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
createForm.require_oauth_only = false;
createForm.require_privacy_set = false;
}
resetDisabledBatchImagePricing(createForm);
resetModelsListState(createModelsListState);
loadModelsListCandidates("create", 0, newVal);
},
);
watch(
() => createForm.allow_image_generation,
() => {
resetDisabledBatchImagePricing(createForm);
},
);
watch(
() => createForm.allow_batch_image_generation,
() => {
resetDisabledBatchImagePricing(createForm);
},
);
watch(
() => editForm.platform,
(newVal) => {
if (!["anthropic", "antigravity"].includes(newVal)) {
editForm.fallback_group_id_on_invalid_request = null;
}
if (newVal !== "openai") {
resetMessagesDispatchFormState(editForm);
editForm.allow_live = false;
}
if (!isProfitControlPlatform(newVal)) {
editForm.profit_control_enabled = false;
editForm.profit_min_margin_percent = 0;
editForm.profit_safety_buffer_percent = 0;
}
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
newVal,
editForm.max_reasoning_effort,
);
editForm.reasoning_effort_mappings = reasoningEffortMappingsToRows(
reasoningEffortMappingsToAPI(editForm.reasoning_effort_mappings),
newVal,
);
editReasoningEffortPolicyRef.value?.resetValidation();
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
editForm.require_oauth_only = false;
editForm.require_privacy_set = false;
}
resetDisabledBatchImagePricing(editForm);
if (editingGroup.value) {
resetModelsListState(editModelsListState, editForm.platform === editingGroup.value.platform ? editingGroup.value.models_list_config : undefined);
loadModelsListCandidates("edit", editingGroup.value.id, newVal);
}
},
);
watch(
() => editForm.allow_image_generation,
() => {
resetDisabledBatchImagePricing(editForm);
},
);
watch(
() => editForm.allow_batch_image_generation,
() => {
resetDisabledBatchImagePricing(editForm);
},
);
watch(
() => editForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
editForm.fallback_group_id_on_invalid_request = null
}
if (newVal !== 'openai') {
editForm.allow_messages_dispatch = false
editForm.allow_live = false
editForm.default_mapped_model = ''
}
}
)
// 点击外部关闭账号搜索下拉框
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 检查是否点击在下拉框或输入框内
if (!target.closest(".account-search-container")) {
Object.keys(showAccountDropdown.value).forEach((key) => {
showAccountDropdown.value[key] = false;
});
}
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false;
}
};
// 打开排序弹窗
const openSortModal = async () => {
try {
// 获取所有分组(不分页)
const allGroups = await adminAPI.groups.getAll();
// 按 sort_order 排序
sortableGroups.value = [...allGroups].sort(
(a, b) => a.sort_order - b.sort_order,
);
showSortModal.value = true;
} catch (error) {
appStore.showError(t("admin.groups.failedToLoad"));
console.error("Error loading groups for sorting:", error);
}
};
// 关闭排序弹窗
const closeSortModal = () => {
showSortModal.value = false;
sortableGroups.value = [];
};
// 保存排序
const saveSortOrder = async () => {
sortSubmitting.value = true;
try {
const updates = sortableGroups.value.map((g, index) => ({
id: g.id,
sort_order: index * 10,
}));
await adminAPI.groups.updateSortOrder(updates);
appStore.showSuccess(t("admin.groups.sortOrderUpdated"));
closeSortModal();
loadGroups();
} catch (error: any) {
appStore.showError(
error.response?.data?.detail || t("admin.groups.failedToUpdateSortOrder"),
);
console.error("Error updating sort order:", error);
} finally {
sortSubmitting.value = false;
}
};
onMounted(() => {
loadGroups();
void loadLiveCapability();
loadModelsListCandidates("create", 0, createForm.platform);
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
accountSearchRunner.clearAll();
clearAllAccountSearchState();
});
</script>