"frontend/src/vscode:/vscode.git/clone" did not exist on "c520de11de6172e018abbdfdd096c3cfc653e771"
Commit de9b9c9d authored by IanShaw027's avatar IanShaw027
Browse files

feat(admin): 增加分组 messages 调度映射配置界面

parent d765359f
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
<AppLayout> <AppLayout>
<TablePageLayout> <TablePageLayout>
<template #filters> <template #filters>
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start"> <div
class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: fuzzy search + filters (can wrap to multiple lines) --> <!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3"> <div class="flex flex-1 flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64"> <div class="relative w-full sm:w-64">
...@@ -19,38 +21,44 @@ ...@@ -19,38 +21,44 @@
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<Select <Select
v-model="filters.platform" v-model="filters.platform"
:options="platformFilterOptions" :options="platformFilterOptions"
:placeholder="t('admin.groups.allPlatforms')" :placeholder="t('admin.groups.allPlatforms')"
class="w-44" class="w-44"
@change="loadGroups" @change="loadGroups"
/> />
<Select <Select
v-model="filters.status" v-model="filters.status"
:options="statusOptions" :options="statusOptions"
:placeholder="t('admin.groups.allStatus')" :placeholder="t('admin.groups.allStatus')"
class="w-40" class="w-40"
@change="loadGroups" @change="loadGroups"
/> />
<Select <Select
v-model="filters.is_exclusive" v-model="filters.is_exclusive"
:options="exclusiveOptions" :options="exclusiveOptions"
:placeholder="t('admin.groups.allGroups')" :placeholder="t('admin.groups.allGroups')"
class="w-44" class="w-44"
@change="loadGroups" @change="loadGroups"
/> />
</div> </div>
<!-- Right: actions --> <!-- Right: actions -->
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"> <div
class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button <button
@click="loadGroups" @click="loadGroups"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
:title="t('common.refresh')" :title="t('common.refresh')"
> >
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" /> <Icon
name="refresh"
size="md"
:class="loading ? 'animate-spin' : ''"
/>
</button> </button>
<button <button
@click="openSortModal" @click="openSortModal"
...@@ -58,7 +66,7 @@ ...@@ -58,7 +66,7 @@
:title="t('admin.groups.sortOrder')" :title="t('admin.groups.sortOrder')"
> >
<Icon name="arrowsUpDown" size="md" class="mr-2" /> <Icon name="arrowsUpDown" size="md" class="mr-2" />
{{ t('admin.groups.sortOrder') }} {{ t("admin.groups.sortOrder") }}
</button> </button>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
...@@ -66,7 +74,7 @@ ...@@ -66,7 +74,7 @@
data-tour="groups-create-btn" data-tour="groups-create-btn"
> >
<Icon name="plus" size="md" class="mr-2" /> <Icon name="plus" size="md" class="mr-2" />
{{ t('admin.groups.createGroup') }} {{ t("admin.groups.createGroup") }}
</button> </button>
</div> </div>
</div> </div>
...@@ -75,7 +83,9 @@ ...@@ -75,7 +83,9 @@
<template #table> <template #table>
<DataTable :columns="columns" :data="groups" :loading="loading"> <DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{
value
}}</span>
</template> </template>
<template #cell-platform="{ value }"> <template #cell-platform="{ value }">
...@@ -88,11 +98,11 @@ ...@@ -88,11 +98,11 @@
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: value === 'antigravity' : value === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]" ]"
> >
<PlatformIcon :platform="value" size="xs" /> <PlatformIcon :platform="value" size="xs" />
{{ t('admin.groups.platforms.' + value) }} {{ t("admin.groups.platforms." + value) }}
</span> </span>
</template> </template>
...@@ -104,13 +114,13 @@ ...@@ -104,13 +114,13 @@
'inline-block rounded-full px-2 py-0.5 text-xs font-medium', 'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
row.subscription_type === 'subscription' row.subscription_type === 'subscription'
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' ? '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' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
]" ]"
> >
{{ {{
row.subscription_type === 'subscription' row.subscription_type === "subscription"
? t('admin.groups.subscription.subscription') ? t("admin.groups.subscription.subscription")
: t('admin.groups.subscription.standard') : t("admin.groups.subscription.standard")
}} }}
</span> </span>
<!-- Subscription Limits - compact single line --> <!-- Subscription Limits - compact single line -->
...@@ -119,18 +129,29 @@ ...@@ -119,18 +129,29 @@
class="text-xs text-gray-500 dark:text-gray-400" class="text-xs text-gray-500 dark:text-gray-400"
> >
<template <template
v-if="row.daily_limit_usd || row.weekly_limit_usd || row.monthly_limit_usd" v-if="
row.daily_limit_usd ||
row.weekly_limit_usd ||
row.monthly_limit_usd
"
> >
<span v-if="row.daily_limit_usd" <span v-if="row.daily_limit_usd"
>${{ row.daily_limit_usd }}/{{ t('admin.groups.limitDay') }}</span >${{ 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)" v-if="
row.daily_limit_usd &&
(row.weekly_limit_usd || row.monthly_limit_usd)
"
class="mx-1 text-gray-300 dark:text-gray-600" class="mx-1 text-gray-300 dark:text-gray-600"
>·</span >·</span
> >
<span v-if="row.weekly_limit_usd" <span v-if="row.weekly_limit_usd"
>${{ row.weekly_limit_usd }}/{{ t('admin.groups.limitWeek') }}</span >${{ row.weekly_limit_usd }}/{{
t("admin.groups.limitWeek")
}}</span
> >
<span <span
v-if="row.weekly_limit_usd && row.monthly_limit_usd" v-if="row.weekly_limit_usd && row.monthly_limit_usd"
...@@ -138,42 +159,75 @@ ...@@ -138,42 +159,75 @@
>·</span >·</span
> >
<span v-if="row.monthly_limit_usd" <span v-if="row.monthly_limit_usd"
>${{ row.monthly_limit_usd }}/{{ t('admin.groups.limitMonth') }}</span >${{ row.monthly_limit_usd }}/{{
t("admin.groups.limitMonth")
}}</span
> >
</template> </template>
<span v-else class="text-gray-400 dark:text-gray-500">{{ <span v-else class="text-gray-400 dark:text-gray-500">{{
t('admin.groups.subscription.noLimit') t("admin.groups.subscription.noLimit")
}}</span> }}</span>
</div> </div>
</div> </div>
</template> </template>
<template #cell-rate_multiplier="{ value }"> <template #cell-rate_multiplier="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}x</span> <span class="text-sm text-gray-700 dark:text-gray-300"
>{{ value }}x</span
>
</template> </template>
<template #cell-is_exclusive="{ value }"> <template #cell-is_exclusive="{ value }">
<span :class="['badge', value ? 'badge-primary' : 'badge-gray']"> <span :class="['badge', value ? 'badge-primary' : 'badge-gray']">
{{ value ? t('admin.groups.exclusive') : t('admin.groups.public') }} {{
value ? t("admin.groups.exclusive") : t("admin.groups.public")
}}
</span> </span>
</template> </template>
<template #cell-account_count="{ row }"> <template #cell-account_count="{ row }">
<div class="space-y-0.5 text-xs"> <div class="space-y-0.5 text-xs">
<div> <div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span> <span class="text-gray-500 dark:text-gray-400">{{
<span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span> t("admin.groups.accountsAvailable")
<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> }}</span>
<span
class="ml-1 font-medium text-emerald-600 dark:text-emerald-400"
>{{
(row.active_account_count || 0) -
(row.rate_limited_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>
<div v-if="row.rate_limited_account_count"> <div v-if="row.rate_limited_account_count">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span> <span class="text-gray-500 dark:text-gray-400">{{
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span> t("admin.groups.accountsRateLimited")
<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> }}</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>
<div> <div>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span> <span class="text-gray-500 dark:text-gray-400">{{
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span> t("admin.groups.accountsTotal")
<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> }}</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>
</div> </div>
</template> </template>
...@@ -195,19 +249,36 @@ ...@@ -195,19 +249,36 @@
<div v-if="usageLoading" class="text-xs text-gray-400"></div> <div v-if="usageLoading" class="text-xs text-gray-400"></div>
<div v-else class="space-y-0.5 text-xs"> <div v-else class="space-y-0.5 text-xs">
<div class="text-gray-500 dark:text-gray-400"> <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="text-gray-400 dark:text-gray-500">{{
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span> 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>
<div class="text-gray-500 dark:text-gray-400"> <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="text-gray-400 dark:text-gray-500">{{
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span> 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>
</div> </div>
</template> </template>
<template #cell-status="{ value }"> <template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']"> <span
{{ t('admin.accounts.status.' + value) }} :class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger',
]"
>
{{ t("admin.accounts.status." + value) }}
</span> </span>
</template> </template>
...@@ -218,21 +289,23 @@ ...@@ -218,21 +289,23 @@
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" 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" /> <Icon name="edit" size="sm" />
<span class="text-xs">{{ t('common.edit') }}</span> <span class="text-xs">{{ t("common.edit") }}</span>
</button> </button>
<button <button
@click="handleRateMultipliers(row)" @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" 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" /> <Icon name="dollar" size="sm" />
<span class="text-xs">{{ t('admin.groups.rateMultipliers') }}</span> <span class="text-xs">{{
t("admin.groups.rateMultipliers")
}}</span>
</button> </button>
<button <button
@click="handleDelete(row)" @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" 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" /> <Icon name="trash" size="sm" />
<span class="text-xs">{{ t('common.delete') }}</span> <span class="text-xs">{{ t("common.delete") }}</span>
</button> </button>
</div> </div>
</template> </template>
...@@ -267,9 +340,13 @@ ...@@ -267,9 +340,13 @@
width="normal" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<form id="create-group-form" @submit.prevent="handleCreateGroup" class="space-y-5"> <form
id="create-group-form"
@submit.prevent="handleCreateGroup"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input <input
v-model="createForm.name" v-model="createForm.name"
type="text" type="text"
...@@ -280,7 +357,9 @@ ...@@ -280,7 +357,9 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.description') }}</label> <label class="input-label">{{
t("admin.groups.form.description")
}}</label>
<textarea <textarea
v-model="createForm.description" v-model="createForm.description"
rows="3" rows="3"
...@@ -289,20 +368,22 @@ ...@@ -289,20 +368,22 @@
></textarea> ></textarea>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.platform') }}</label> <label class="input-label">{{
t("admin.groups.form.platform")
}}</label>
<Select <Select
v-model="createForm.platform" v-model="createForm.platform"
:options="platformOptions" :options="platformOptions"
data-tour="group-form-platform" data-tour="group-form-platform"
@change="createForm.copy_accounts_from_group_ids = []" @change="createForm.copy_accounts_from_group_ids = []"
/> />
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p> <p class="input-hint">{{ t("admin.groups.platformHint") }}</p>
</div> </div>
<!-- 从分组复制账号 --> <!-- 从分组复制账号 -->
<div v-if="copyAccountsGroupOptions.length > 0"> <div v-if="copyAccountsGroupOptions.length > 0">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }} {{ t("admin.groups.copyAccounts.title") }}
</label> </label>
<div class="group relative inline-flex"> <div class="group relative inline-flex">
<Icon <Icon
...@@ -311,27 +392,44 @@ ...@@ -311,27 +392,44 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltip') }} {{ t("admin.groups.copyAccounts.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
</div> </div>
<!-- 已选分组标签 --> <!-- 已选分组标签 -->
<div v-if="createForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2"> <div
v-if="createForm.copy_accounts_from_group_ids.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span <span
v-for="groupId in createForm.copy_accounts_from_group_ids" v-for="groupId in createForm.copy_accounts_from_group_ids"
:key="groupId" :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" 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}` }} {{
copyAccountsGroupOptions.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button <button
type="button" type="button"
@click="createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)" @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" class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
> >
<Icon name="x" size="xs" /> <Icon name="x" size="xs" />
...@@ -341,28 +439,39 @@ ...@@ -341,28 +439,39 @@
<!-- 分组选择下拉 --> <!-- 分组选择下拉 -->
<select <select
class="input" class="input"
@change="(e) => { @change="
const val = Number((e.target as HTMLSelectElement).value) (e) => {
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) { const val = Number((e.target as HTMLSelectElement).value);
createForm.copy_accounts_from_group_ids.push(val) if (
val &&
!createForm.copy_accounts_from_group_ids.includes(val)
) {
createForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
} }
(e.target as HTMLSelectElement).value = '' "
}"
> >
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option> <option value="">
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option <option
v-for="opt in copyAccountsGroupOptions" v-for="opt in copyAccountsGroupOptions"
:key="opt.value" :key="opt.value"
:value="opt.value" :value="opt.value"
:disabled="createForm.copy_accounts_from_group_ids.includes(opt.value)" :disabled="
createForm.copy_accounts_from_group_ids.includes(opt.value)
"
> >
{{ opt.label }} {{ opt.label }}
</option> </option>
</select> </select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hint') }}</p> <p class="input-hint">{{ t("admin.groups.copyAccounts.hint") }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{
t("admin.groups.form.rateMultiplier")
}}</label>
<input <input
v-model.number="createForm.rate_multiplier" v-model.number="createForm.rate_multiplier"
type="number" type="number"
...@@ -372,12 +481,15 @@ ...@@ -372,12 +481,15 @@
class="input" class="input"
data-tour="group-form-multiplier" data-tour="group-form-multiplier"
/> />
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p> <p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p>
</div> </div>
<div v-if="createForm.subscription_type !== 'subscription'" data-tour="group-form-exclusive"> <div
v-if="createForm.subscription_type !== 'subscription'"
data-tour="group-form-exclusive"
>
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.form.exclusive') }} {{ t("admin.groups.form.exclusive") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -388,20 +500,32 @@ ...@@ -388,20 +500,32 @@
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/> />
<!-- Tooltip Popover --> <!-- 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p> >
<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"> <p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }} {{ t("admin.groups.exclusiveTooltip.description") }}
</p> </p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700"> <div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300"> <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> <span
{{ t('admin.groups.exclusiveTooltip.exampleContent') }} 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> </p>
</div> </div>
<!-- Arrow --> <!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -412,18 +536,24 @@ ...@@ -412,18 +536,24 @@
@click="createForm.is_exclusive = !createForm.is_exclusive" @click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' createForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }} {{
createForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span> </span>
</div> </div>
</div> </div>
...@@ -431,9 +561,16 @@ ...@@ -431,9 +561,16 @@
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" /> t("admin.groups.subscription.type")
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p> }}</label>
<Select
v-model="createForm.subscription_type"
:options="subscriptionTypeOptions"
/>
<p class="input-hint">
{{ t("admin.groups.subscription.typeHint") }}
</p>
</div> </div>
<!-- Subscription limits (only show when subscription type is selected) --> <!-- Subscription limits (only show when subscription type is selected) -->
...@@ -442,7 +579,9 @@ ...@@ -442,7 +579,9 @@
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800" class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
> >
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.dailyLimit")
}}</label>
<input <input
v-model.number="createForm.daily_limit_usd" v-model.number="createForm.daily_limit_usd"
type="number" type="number"
...@@ -453,7 +592,9 @@ ...@@ -453,7 +592,9 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.weeklyLimit")
}}</label>
<input <input
v-model.number="createForm.weekly_limit_usd" v-model.number="createForm.weekly_limit_usd"
type="number" type="number"
...@@ -464,7 +605,9 @@ ...@@ -464,7 +605,9 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.monthlyLimit")
}}</label>
<input <input
v-model.number="createForm.monthly_limit_usd" v-model.number="createForm.monthly_limit_usd"
type="number" type="number"
...@@ -478,12 +621,20 @@ ...@@ -478,12 +621,20 @@
</div> </div>
<!-- 图片生成计费配置(antigravity 和 gemini 平台) --> <!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
<div v-if="createForm.platform === 'antigravity' || createForm.platform === 'gemini'" class="border-t pt-4"> <div
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300"> v-if="
{{ t('admin.groups.imagePricing.title') }} createForm.platform === 'antigravity' ||
createForm.platform === 'gemini'
"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.groups.imagePricing.title") }}
</label> </label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.imagePricing.description') }} {{ t("admin.groups.imagePricing.description") }}
</p> </p>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div> <div>
...@@ -522,13 +673,11 @@ ...@@ -522,13 +673,11 @@
</div> </div>
</div> </div>
<!-- 支持的模型系列(仅 antigravity 平台) --> <!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4"> <div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }} {{ t("admin.groups.supportedScopes.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -538,12 +687,18 @@ ...@@ -538,12 +687,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }} {{ t("admin.groups.supportedScopes.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -556,35 +711,47 @@ ...@@ -556,35 +711,47 @@
@change="toggleCreateScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.claude")
}}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_text')" :checked="
createForm.supported_model_scopes.includes('gemini_text')
"
@change="toggleCreateScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiText")
}}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_image')" :checked="
createForm.supported_model_scopes.includes('gemini_image')
"
@change="toggleCreateScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiImage")
}}</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div> </div>
<!-- MCP XML 协议注入(仅 antigravity 平台) --> <!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4"> <div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }} {{ t("admin.groups.mcpXml.title") }}
</label> </label>
<div class="group relative inline-flex"> <div class="group relative inline-flex">
<Icon <Icon
...@@ -593,12 +760,18 @@ ...@@ -593,12 +760,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }} {{ t("admin.groups.mcpXml.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -609,18 +782,24 @@ ...@@ -609,18 +782,24 @@
@click="createForm.mcp_xml_inject = !createForm.mcp_xml_inject" @click="createForm.mcp_xml_inject = !createForm.mcp_xml_inject"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' createForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1' createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
createForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span> </span>
</div> </div>
</div> </div>
...@@ -629,7 +808,7 @@ ...@@ -629,7 +808,7 @@
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }} {{ t("admin.groups.claudeCode.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -639,12 +818,18 @@ ...@@ -639,12 +818,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }} {{ t("admin.groups.claudeCode.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -652,97 +837,321 @@ ...@@ -652,97 +837,321 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="createForm.claude_code_only = !createForm.claude_code_only" @click="
createForm.claude_code_only = !createForm.claude_code_only
"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' createForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.claude_code_only ? 'translate-x-6' : 'translate-x-1' createForm.claude_code_only
? 'translate-x-6'
: 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
createForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span> </span>
</div> </div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) --> <!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div v-if="createForm.claude_code_only" class="mt-3"> <div v-if="createForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label> <label class="input-label">{{
t("admin.groups.claudeCode.fallbackGroup")
}}</label>
<Select <Select
v-model="createForm.fallback_group_id" v-model="createForm.fallback_group_id"
:options="fallbackGroupOptions" :options="fallbackGroupOptions"
:placeholder="t('admin.groups.claudeCode.noFallback')" :placeholder="t('admin.groups.claudeCode.noFallback')"
/> />
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p> <p class="input-hint">
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div> </div>
</div> </div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) --> <!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div v-if="createForm.platform === 'openai'" class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('admin.groups.openaiMessages.title') }}</h4> 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 调度开关 --> <!-- 允许 Messages 调度开关 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{ t('admin.groups.openaiMessages.allowDispatch') }}</label> <label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiMessages.allowDispatch")
}}</label>
<button <button
type="button" type="button"
@click="createForm.allow_messages_dispatch = !createForm.allow_messages_dispatch" @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="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=" :class="
createForm.allow_messages_dispatch ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' createForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
createForm.allow_messages_dispatch ? 'translate-x-6' : 'translate-x-1' createForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.groups.openaiMessages.allowDispatchHint') }}</p> <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 v-if="createForm.allow_messages_dispatch" class="mt-3">
<label class="input-label">{{ t('admin.groups.openaiMessages.defaultModel') }}</label> <div
<input class="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
v-model="createForm.default_mapped_model" >
type="text" <div
:placeholder="t('admin.groups.openaiMessages.defaultModelPlaceholder')" class="border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
class="input" >
/> <div class="flex items-center gap-2">
<p class="input-hint">{{ t('admin.groups.openaiMessages.defaultModelHint') }}</p> <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>
</div> </div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) --> <!-- 账号过滤控制 (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"> <div
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">账号过滤控制</h4> 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">
账号过滤控制
</h4>
<!-- require_oauth_only toggle --> <!-- require_oauth_only toggle -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">仅允许 OAuth 账号</label> <label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许 OAuth 账号</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ createForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }} {{
createForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
}}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
@click="createForm.require_oauth_only = !createForm.require_oauth_only" @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="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=" :class="
createForm.require_oauth_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' createForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
createForm.require_oauth_only ? 'translate-x-6' : 'translate-x-1' createForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
...@@ -751,23 +1160,35 @@ ...@@ -751,23 +1160,35 @@
<!-- require_privacy_set toggle --> <!-- require_privacy_set toggle -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">仅允许隐私保护已设置的账号</label> <label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许隐私保护已设置的账号</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ createForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }} {{
createForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
}}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
@click="createForm.require_privacy_set = !createForm.require_privacy_set" @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="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=" :class="
createForm.require_privacy_set ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' createForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
createForm.require_privacy_set ? 'translate-x-6' : 'translate-x-1' createForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
...@@ -776,23 +1197,30 @@ ...@@ -776,23 +1197,30 @@
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) --> <!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div <div
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'" v-if="
['anthropic', 'antigravity'].includes(createForm.platform) &&
createForm.subscription_type !== 'subscription'
"
class="border-t pt-4" class="border-t pt-4"
> >
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label> <label class="input-label">{{
t("admin.groups.invalidRequestFallback.title")
}}</label>
<Select <Select
v-model="createForm.fallback_group_id_on_invalid_request" v-model="createForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptions" :options="invalidRequestFallbackOptions"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')" :placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/> />
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p> <p class="input-hint">
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div> </div>
<!-- 模型路由配置(仅 anthropic 平台) --> <!-- 模型路由配置(仅 anthropic 平台) -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.modelRouting.title') }} {{ t("admin.groups.modelRouting.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -802,12 +1230,18 @@ ...@@ -802,12 +1230,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.modelRouting.tooltip') }} {{ t("admin.groups.modelRouting.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -816,28 +1250,42 @@ ...@@ -816,28 +1250,42 @@
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center gap-3 mb-3">
<button <button
type="button" type="button"
@click="createForm.model_routing_enabled = !createForm.model_routing_enabled" @click="
createForm.model_routing_enabled =
!createForm.model_routing_enabled
"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' createForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1' createForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
createForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span> </span>
</div> </div>
<p v-if="!createForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p
{{ t('admin.groups.modelRouting.disabledHint') }} 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>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.noRulesHint') }} {{ t("admin.groups.modelRouting.noRulesHint") }}
</p> </p>
<!-- 路由规则列表(仅在启用时显示) --> <!-- 路由规则列表(仅在启用时显示) -->
<div v-if="createForm.model_routing_enabled" class="space-y-3"> <div v-if="createForm.model_routing_enabled" class="space-y-3">
...@@ -849,18 +1297,27 @@ ...@@ -849,18 +1297,27 @@
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div> <div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label> <label class="input-label text-xs">{{
t("admin.groups.modelRouting.modelPattern")
}}</label>
<input <input
v-model="rule.pattern" v-model="rule.pattern"
type="text" type="text"
class="input text-sm" class="input text-sm"
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')" :placeholder="
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/> />
</div> </div>
<div> <div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label> <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"> <div
v-if="rule.accounts.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span <span
v-for="account in rule.accounts" v-for="account in rule.accounts"
:key="account.id" :key="account.id"
...@@ -879,33 +1336,55 @@ ...@@ -879,33 +1336,55 @@
<!-- 账号搜索输入框 --> <!-- 账号搜索输入框 -->
<div class="relative account-search-container"> <div class="relative account-search-container">
<input <input
v-model="accountSearchKeyword[getCreateRuleSearchKey(rule)]" v-model="
accountSearchKeyword[getCreateRuleSearchKey(rule)]
"
type="text" type="text"
class="input text-sm" class="input text-sm"
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')" :placeholder="
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@input="searchAccountsByRule(rule)" @input="searchAccountsByRule(rule)"
@focus="onAccountSearchFocus(rule)" @focus="onAccountSearchFocus(rule)"
/> />
<!-- 搜索结果下拉框 --> <!-- 搜索结果下拉框 -->
<div <div
v-if="showAccountDropdown[getCreateRuleSearchKey(rule)] && accountSearchResults[getCreateRuleSearchKey(rule)]?.length > 0" 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" 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 <button
v-for="account in accountSearchResults[getCreateRuleSearchKey(rule)]" v-for="account in accountSearchResults[
getCreateRuleSearchKey(rule)
]"
:key="account.id" :key="account.id"
type="button" type="button"
@click="selectAccount(rule, account)" @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="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) }" :class="{
:disabled="rule.accounts.some(a => a.id === account.id)" 'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled="
rule.accounts.some((a) => a.id === account.id)
"
> >
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span> <span class="ml-2 text-xs text-gray-400"
>#{{ account.id }}</span
>
</button> </button>
</div> </div>
</div> </div>
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p> <p class="text-xs text-gray-400 mt-1">
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div> </div>
</div> </div>
<button <button
...@@ -927,16 +1406,19 @@ ...@@ -927,16 +1406,19 @@
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" 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" /> <Icon name="plus" size="sm" />
{{ t('admin.groups.modelRouting.addRule') }} {{ t("admin.groups.modelRouting.addRule") }}
</button> </button>
</div> </div>
</form> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button
{{ t('common.cancel') }} @click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button> </button>
<button <button
type="submit" type="submit"
...@@ -965,7 +1447,7 @@ ...@@ -965,7 +1447,7 @@
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" 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> ></path>
</svg> </svg>
{{ submitting ? t('admin.groups.creating') : t('common.create') }} {{ submitting ? t("admin.groups.creating") : t("common.create") }}
</button> </button>
</div> </div>
</template> </template>
...@@ -985,7 +1467,7 @@ ...@@ -985,7 +1467,7 @@
class="space-y-5" class="space-y-5"
> >
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t("admin.groups.form.name") }}</label>
<input <input
v-model="editForm.name" v-model="editForm.name"
type="text" type="text"
...@@ -995,24 +1477,32 @@ ...@@ -995,24 +1477,32 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.description') }}</label> <label class="input-label">{{
<textarea v-model="editForm.description" rows="3" class="input"></textarea> t("admin.groups.form.description")
}}</label>
<textarea
v-model="editForm.description"
rows="3"
class="input"
></textarea>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.platform') }}</label> <label class="input-label">{{
t("admin.groups.form.platform")
}}</label>
<Select <Select
v-model="editForm.platform" v-model="editForm.platform"
:options="platformOptions" :options="platformOptions"
:disabled="true" :disabled="true"
data-tour="group-form-platform" data-tour="group-form-platform"
/> />
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p> <p class="input-hint">{{ t("admin.groups.platformNotEditable") }}</p>
</div> </div>
<!-- 从分组复制账号(编辑时) --> <!-- 从分组复制账号(编辑时) -->
<div v-if="copyAccountsGroupOptionsForEdit.length > 0"> <div v-if="copyAccountsGroupOptionsForEdit.length > 0">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }} {{ t("admin.groups.copyAccounts.title") }}
</label> </label>
<div class="group relative inline-flex"> <div class="group relative inline-flex">
<Icon <Icon
...@@ -1021,27 +1511,44 @@ ...@@ -1021,27 +1511,44 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltipEdit') }} {{ t("admin.groups.copyAccounts.tooltipEdit") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
</div> </div>
<!-- 已选分组标签 --> <!-- 已选分组标签 -->
<div v-if="editForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2"> <div
v-if="editForm.copy_accounts_from_group_ids.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span <span
v-for="groupId in editForm.copy_accounts_from_group_ids" v-for="groupId in editForm.copy_accounts_from_group_ids"
:key="groupId" :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" 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}` }} {{
copyAccountsGroupOptionsForEdit.find((o) => o.value === groupId)
?.label || `#${groupId}`
}}
<button <button
type="button" type="button"
@click="editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)" @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" class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
> >
<Icon name="x" size="xs" /> <Icon name="x" size="xs" />
...@@ -1051,28 +1558,41 @@ ...@@ -1051,28 +1558,41 @@
<!-- 分组选择下拉 --> <!-- 分组选择下拉 -->
<select <select
class="input" class="input"
@change="(e) => { @change="
const val = Number((e.target as HTMLSelectElement).value) (e) => {
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) { const val = Number((e.target as HTMLSelectElement).value);
editForm.copy_accounts_from_group_ids.push(val) if (
val &&
!editForm.copy_accounts_from_group_ids.includes(val)
) {
editForm.copy_accounts_from_group_ids.push(val);
}
(e.target as HTMLSelectElement).value = '';
} }
(e.target as HTMLSelectElement).value = '' "
}"
> >
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option> <option value="">
{{ t("admin.groups.copyAccounts.selectPlaceholder") }}
</option>
<option <option
v-for="opt in copyAccountsGroupOptionsForEdit" v-for="opt in copyAccountsGroupOptionsForEdit"
:key="opt.value" :key="opt.value"
:value="opt.value" :value="opt.value"
:disabled="editForm.copy_accounts_from_group_ids.includes(opt.value)" :disabled="
editForm.copy_accounts_from_group_ids.includes(opt.value)
"
> >
{{ opt.label }} {{ opt.label }}
</option> </option>
</select> </select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hintEdit') }}</p> <p class="input-hint">
{{ t("admin.groups.copyAccounts.hintEdit") }}
</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{
t("admin.groups.form.rateMultiplier")
}}</label>
<input <input
v-model.number="editForm.rate_multiplier" v-model.number="editForm.rate_multiplier"
type="number" type="number"
...@@ -1086,7 +1606,7 @@ ...@@ -1086,7 +1606,7 @@
<div v-if="editForm.subscription_type !== 'subscription'"> <div v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.form.exclusive') }} {{ t("admin.groups.form.exclusive") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -1097,20 +1617,32 @@ ...@@ -1097,20 +1617,32 @@
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/> />
<!-- Tooltip Popover --> <!-- 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p> >
<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"> <p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }} {{ t("admin.groups.exclusiveTooltip.description") }}
</p> </p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700"> <div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300"> <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> <span
{{ t('admin.groups.exclusiveTooltip.exampleContent') }} 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> </p>
</div> </div>
<!-- Arrow --> <!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -1121,36 +1653,46 @@ ...@@ -1121,36 +1653,46 @@
@click="editForm.is_exclusive = !editForm.is_exclusive" @click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' editForm.is_exclusive
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }} {{
editForm.is_exclusive
? t("admin.groups.exclusive")
: t("admin.groups.public")
}}
</span> </span>
</div> </div>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label> <label class="input-label">{{ t("admin.groups.form.status") }}</label>
<Select v-model="editForm.status" :options="editStatusOptions" /> <Select v-model="editForm.status" :options="editStatusOptions" />
</div> </div>
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{
t("admin.groups.subscription.type")
}}</label>
<Select <Select
v-model="editForm.subscription_type" v-model="editForm.subscription_type"
:options="subscriptionTypeOptions" :options="subscriptionTypeOptions"
:disabled="true" :disabled="true"
/> />
<p class="input-hint">{{ t('admin.groups.subscription.typeNotEditable') }}</p> <p class="input-hint">
{{ t("admin.groups.subscription.typeNotEditable") }}
</p>
</div> </div>
<!-- Subscription limits (only show when subscription type is selected) --> <!-- Subscription limits (only show when subscription type is selected) -->
...@@ -1159,7 +1701,9 @@ ...@@ -1159,7 +1701,9 @@
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800" class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
> >
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.dailyLimit")
}}</label>
<input <input
v-model.number="editForm.daily_limit_usd" v-model.number="editForm.daily_limit_usd"
type="number" type="number"
...@@ -1170,7 +1714,9 @@ ...@@ -1170,7 +1714,9 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.weeklyLimit")
}}</label>
<input <input
v-model.number="editForm.weekly_limit_usd" v-model.number="editForm.weekly_limit_usd"
type="number" type="number"
...@@ -1181,7 +1727,9 @@ ...@@ -1181,7 +1727,9 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label> <label class="input-label">{{
t("admin.groups.subscription.monthlyLimit")
}}</label>
<input <input
v-model.number="editForm.monthly_limit_usd" v-model.number="editForm.monthly_limit_usd"
type="number" type="number"
...@@ -1195,12 +1743,20 @@ ...@@ -1195,12 +1743,20 @@
</div> </div>
<!-- 图片生成计费配置(antigravity 和 gemini 平台) --> <!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
<div v-if="editForm.platform === 'antigravity' || editForm.platform === 'gemini'" class="border-t pt-4"> <div
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300"> v-if="
{{ t('admin.groups.imagePricing.title') }} editForm.platform === 'antigravity' ||
editForm.platform === 'gemini'
"
class="border-t pt-4"
>
<label
class="block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.groups.imagePricing.title") }}
</label> </label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.imagePricing.description') }} {{ t("admin.groups.imagePricing.description") }}
</p> </p>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div> <div>
...@@ -1239,13 +1795,11 @@ ...@@ -1239,13 +1795,11 @@
</div> </div>
</div> </div>
<!-- 支持的模型系列(仅 antigravity 平台) --> <!-- 支持的模型系列(仅 antigravity 平台) -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4"> <div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }} {{ t("admin.groups.supportedScopes.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -1255,12 +1809,18 @@ ...@@ -1255,12 +1809,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }} {{ t("admin.groups.supportedScopes.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -1273,35 +1833,47 @@ ...@@ -1273,35 +1833,47 @@
@change="toggleEditScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.claude")
}}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_text')" :checked="
editForm.supported_model_scopes.includes('gemini_text')
"
@change="toggleEditScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiText")
}}</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_image')" :checked="
editForm.supported_model_scopes.includes('gemini_image')
"
@change="toggleEditScope('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" 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> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t("admin.groups.supportedScopes.geminiImage")
}}</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.supportedScopes.hint") }}
</p>
</div> </div>
<!-- MCP XML 协议注入(仅 antigravity 平台) --> <!-- MCP XML 协议注入(仅 antigravity 平台) -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4"> <div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }} {{ t("admin.groups.mcpXml.title") }}
</label> </label>
<div class="group relative inline-flex"> <div class="group relative inline-flex">
<Icon <Icon
...@@ -1310,12 +1882,18 @@ ...@@ -1310,12 +1882,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }} {{ t("admin.groups.mcpXml.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -1326,18 +1904,24 @@ ...@@ -1326,18 +1904,24 @@
@click="editForm.mcp_xml_inject = !editForm.mcp_xml_inject" @click="editForm.mcp_xml_inject = !editForm.mcp_xml_inject"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' editForm.mcp_xml_inject
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1' editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
editForm.mcp_xml_inject
? t("admin.groups.mcpXml.enabled")
: t("admin.groups.mcpXml.disabled")
}}
</span> </span>
</div> </div>
</div> </div>
...@@ -1346,7 +1930,7 @@ ...@@ -1346,7 +1930,7 @@
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }} {{ t("admin.groups.claudeCode.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -1356,12 +1940,18 @@ ...@@ -1356,12 +1940,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }} {{ t("admin.groups.claudeCode.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -1372,94 +1962,314 @@ ...@@ -1372,94 +1962,314 @@
@click="editForm.claude_code_only = !editForm.claude_code_only" @click="editForm.claude_code_only = !editForm.claude_code_only"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' editForm.claude_code_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1' editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
editForm.claude_code_only
? t("admin.groups.claudeCode.enabled")
: t("admin.groups.claudeCode.disabled")
}}
</span> </span>
</div> </div>
<!-- 降级分组选择(仅当启用 claude_code_only 时显示) --> <!-- 降级分组选择(仅当启用 claude_code_only 时显示) -->
<div v-if="editForm.claude_code_only" class="mt-3"> <div v-if="editForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label> <label class="input-label">{{
t("admin.groups.claudeCode.fallbackGroup")
}}</label>
<Select <Select
v-model="editForm.fallback_group_id" v-model="editForm.fallback_group_id"
:options="fallbackGroupOptionsForEdit" :options="fallbackGroupOptionsForEdit"
:placeholder="t('admin.groups.claudeCode.noFallback')" :placeholder="t('admin.groups.claudeCode.noFallback')"
/> />
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p> <p class="input-hint">
{{ t("admin.groups.claudeCode.fallbackHint") }}
</p>
</div> </div>
</div> </div>
<!-- OpenAI Messages 调度配置(仅 openai 平台) --> <!-- OpenAI Messages 调度配置(仅 openai 平台) -->
<div v-if="editForm.platform === 'openai'" class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('admin.groups.openaiMessages.title') }}</h4> 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 调度开关 --> <!-- 允许 Messages 调度开关 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-400">{{ t('admin.groups.openaiMessages.allowDispatch') }}</label> <label class="text-sm text-gray-600 dark:text-gray-400">{{
t("admin.groups.openaiMessages.allowDispatch")
}}</label>
<button <button
type="button" type="button"
@click="editForm.allow_messages_dispatch = !editForm.allow_messages_dispatch" @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="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=" :class="
editForm.allow_messages_dispatch ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' editForm.allow_messages_dispatch
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
editForm.allow_messages_dispatch ? 'translate-x-6' : 'translate-x-1' editForm.allow_messages_dispatch
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.groups.openaiMessages.allowDispatchHint') }}</p> <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 v-if="editForm.allow_messages_dispatch" class="mt-3">
<label class="input-label">{{ t('admin.groups.openaiMessages.defaultModel') }}</label> <div
<input class="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-dark-600 dark:bg-dark-800"
v-model="editForm.default_mapped_model" >
type="text" <div
:placeholder="t('admin.groups.openaiMessages.defaultModelPlaceholder')" class="border-b border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-700/50"
class="input" >
/> <div class="flex items-center gap-2">
<p class="input-hint">{{ t('admin.groups.openaiMessages.defaultModelHint') }}</p> <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>
</div> </div>
<!-- 账号过滤控制 (OpenAI/Antigravity/Anthropic/Gemini) --> <!-- 账号过滤控制 (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"> <div
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">账号过滤控制</h4> 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">
账号过滤控制
</h4>
<!-- require_oauth_only toggle --> <!-- require_oauth_only toggle -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">仅允许 OAuth 账号</label> <label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许 OAuth 账号</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ editForm.require_oauth_only ? '已启用 — 排除 API Key 类型账号' : '未启用' }} {{
editForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
}}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
@click="editForm.require_oauth_only = !editForm.require_oauth_only" @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="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=" :class="
editForm.require_oauth_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' editForm.require_oauth_only
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
editForm.require_oauth_only ? 'translate-x-6' : 'translate-x-1' editForm.require_oauth_only
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
...@@ -1468,23 +2278,35 @@ ...@@ -1468,23 +2278,35 @@
<!-- require_privacy_set toggle --> <!-- require_privacy_set toggle -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">仅允许隐私保护已设置的账号</label> <label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许隐私保护已设置的账号</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ editForm.require_privacy_set ? '已启用 — Privacy 未设置的账号将被排除' : '未启用' }} {{
editForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
}}
</p> </p>
</div> </div>
<button <button
type="button" type="button"
@click="editForm.require_privacy_set = !editForm.require_privacy_set" @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="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=" :class="
editForm.require_privacy_set ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' editForm.require_privacy_set
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600'
" "
> >
<span <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="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=" :class="
editForm.require_privacy_set ? 'translate-x-6' : 'translate-x-1' editForm.require_privacy_set
? 'translate-x-6'
: 'translate-x-1'
" "
/> />
</button> </button>
...@@ -1493,23 +2315,30 @@ ...@@ -1493,23 +2315,30 @@
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) --> <!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div <div
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'" v-if="
['anthropic', 'antigravity'].includes(editForm.platform) &&
editForm.subscription_type !== 'subscription'
"
class="border-t pt-4" class="border-t pt-4"
> >
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label> <label class="input-label">{{
t("admin.groups.invalidRequestFallback.title")
}}</label>
<Select <Select
v-model="editForm.fallback_group_id_on_invalid_request" v-model="editForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptionsForEdit" :options="invalidRequestFallbackOptionsForEdit"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')" :placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/> />
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p> <p class="input-hint">
{{ t("admin.groups.invalidRequestFallback.hint") }}
</p>
</div> </div>
<!-- 模型路由配置(仅 anthropic 平台) --> <!-- 模型路由配置(仅 anthropic 平台) -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4"> <div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.modelRouting.title') }} {{ t("admin.groups.modelRouting.title") }}
</label> </label>
<!-- Help Tooltip --> <!-- Help Tooltip -->
<div class="group relative inline-flex"> <div class="group relative inline-flex">
...@@ -1519,12 +2348,18 @@ ...@@ -1519,12 +2348,18 @@
:stroke-width="2" :stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" 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
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800"> 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"> <p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.modelRouting.tooltip') }} {{ t("admin.groups.modelRouting.tooltip") }}
</p> </p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div> <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> </div>
...@@ -1533,28 +2368,41 @@ ...@@ -1533,28 +2368,41 @@
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center gap-3 mb-3">
<button <button
type="button" type="button"
@click="editForm.model_routing_enabled = !editForm.model_routing_enabled" @click="
editForm.model_routing_enabled = !editForm.model_routing_enabled
"
:class="[ :class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', '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' editForm.model_routing_enabled
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]" ]"
> >
<span <span
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.model_routing_enabled ? 'translate-x-6' : 'translate-x-1' editForm.model_routing_enabled
? 'translate-x-6'
: 'translate-x-1',
]" ]"
/> />
</button> </button>
<span class="text-sm text-gray-500 dark:text-gray-400"> <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') }} {{
editForm.model_routing_enabled
? t("admin.groups.modelRouting.enabled")
: t("admin.groups.modelRouting.disabled")
}}
</span> </span>
</div> </div>
<p v-if="!editForm.model_routing_enabled" class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p
{{ t('admin.groups.modelRouting.disabledHint') }} 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>
<p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3"> <p v-else class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.modelRouting.noRulesHint') }} {{ t("admin.groups.modelRouting.noRulesHint") }}
</p> </p>
<!-- 路由规则列表(仅在启用时显示) --> <!-- 路由规则列表(仅在启用时显示) -->
<div v-if="editForm.model_routing_enabled" class="space-y-3"> <div v-if="editForm.model_routing_enabled" class="space-y-3">
...@@ -1566,18 +2414,27 @@ ...@@ -1566,18 +2414,27 @@
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div> <div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.modelPattern') }}</label> <label class="input-label text-xs">{{
t("admin.groups.modelRouting.modelPattern")
}}</label>
<input <input
v-model="rule.pattern" v-model="rule.pattern"
type="text" type="text"
class="input text-sm" class="input text-sm"
:placeholder="t('admin.groups.modelRouting.modelPatternPlaceholder')" :placeholder="
t('admin.groups.modelRouting.modelPatternPlaceholder')
"
/> />
</div> </div>
<div> <div>
<label class="input-label text-xs">{{ t('admin.groups.modelRouting.accounts') }}</label> <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"> <div
v-if="rule.accounts.length > 0"
class="flex flex-wrap gap-1.5 mb-2"
>
<span <span
v-for="account in rule.accounts" v-for="account in rule.accounts"
:key="account.id" :key="account.id"
...@@ -1596,33 +2453,55 @@ ...@@ -1596,33 +2453,55 @@
<!-- 账号搜索输入框 --> <!-- 账号搜索输入框 -->
<div class="relative account-search-container"> <div class="relative account-search-container">
<input <input
v-model="accountSearchKeyword[getEditRuleSearchKey(rule)]" v-model="
accountSearchKeyword[getEditRuleSearchKey(rule)]
"
type="text" type="text"
class="input text-sm" class="input text-sm"
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')" :placeholder="
t(
'admin.groups.modelRouting.searchAccountPlaceholder',
)
"
@input="searchAccountsByRule(rule, true)" @input="searchAccountsByRule(rule, true)"
@focus="onAccountSearchFocus(rule, true)" @focus="onAccountSearchFocus(rule, true)"
/> />
<!-- 搜索结果下拉框 --> <!-- 搜索结果下拉框 -->
<div <div
v-if="showAccountDropdown[getEditRuleSearchKey(rule)] && accountSearchResults[getEditRuleSearchKey(rule)]?.length > 0" 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" 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 <button
v-for="account in accountSearchResults[getEditRuleSearchKey(rule)]" v-for="account in accountSearchResults[
getEditRuleSearchKey(rule)
]"
:key="account.id" :key="account.id"
type="button" type="button"
@click="selectAccount(rule, account, true)" @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="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) }" :class="{
:disabled="rule.accounts.some(a => a.id === account.id)" 'opacity-50': rule.accounts.some(
(a) => a.id === account.id,
),
}"
:disabled="
rule.accounts.some((a) => a.id === account.id)
"
> >
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span> <span class="ml-2 text-xs text-gray-400"
>#{{ account.id }}</span
>
</button> </button>
</div> </div>
</div> </div>
<p class="text-xs text-gray-400 mt-1">{{ t('admin.groups.modelRouting.accountsHint') }}</p> <p class="text-xs text-gray-400 mt-1">
{{ t("admin.groups.modelRouting.accountsHint") }}
</p>
</div> </div>
</div> </div>
<button <button
...@@ -1644,16 +2523,19 @@ ...@@ -1644,16 +2523,19 @@
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" 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" /> <Icon name="plus" size="sm" />
{{ t('admin.groups.modelRouting.addRule') }} {{ t("admin.groups.modelRouting.addRule") }}
</button> </button>
</div> </div>
</form> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button
{{ t('common.cancel') }} @click="closeEditModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button> </button>
<button <button
type="submit" type="submit"
...@@ -1682,7 +2564,7 @@ ...@@ -1682,7 +2564,7 @@
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" 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> ></path>
</svg> </svg>
{{ submitting ? t('admin.groups.updating') : t('common.update') }} {{ submitting ? t("admin.groups.updating") : t("common.update") }}
</button> </button>
</div> </div>
</template> </template>
...@@ -1709,7 +2591,7 @@ ...@@ -1709,7 +2591,7 @@
> >
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.groups.sortOrderHint') }} {{ t("admin.groups.sortOrderHint") }}
</p> </p>
<VueDraggable <VueDraggable
v-model="sortableGroups" v-model="sortableGroups"
...@@ -1725,7 +2607,9 @@ ...@@ -1725,7 +2607,9 @@
<Icon name="menu" size="md" /> <Icon name="menu" size="md" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ group.name }}</div> <div class="font-medium text-gray-900 dark:text-white">
{{ group.name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
<span <span
:class="[ :class="[
...@@ -1736,24 +2620,26 @@ ...@@ -1736,24 +2620,26 @@
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: group.platform === 'antigravity' : group.platform === 'antigravity'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
]" ]"
> >
{{ t('admin.groups.platforms.' + group.platform) }} {{ t("admin.groups.platforms." + group.platform) }}
</span> </span>
</div> </div>
</div> </div>
<div class="text-sm text-gray-400"> <div class="text-sm text-gray-400">#{{ group.id }}</div>
#{{ group.id }}
</div>
</div> </div>
</VueDraggable> </VueDraggable>
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeSortModal" type="button" class="btn btn-secondary"> <button
{{ t('common.cancel') }} @click="closeSortModal"
type="button"
class="btn btn-secondary"
>
{{ t("common.cancel") }}
</button> </button>
<button <button
@click="saveSortOrder" @click="saveSortOrder"
...@@ -1780,7 +2666,7 @@ ...@@ -1780,7 +2666,7 @@
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" 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> ></path>
</svg> </svg>
{{ sortSubmitting ? t('common.saving') : t('common.save') }} {{ sortSubmitting ? t("common.saving") : t("common.save") }}
</button> </button>
</div> </div>
</template> </template>
...@@ -1797,214 +2683,271 @@ ...@@ -1797,214 +2683,271 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
import { useOnboardingStore } from '@/stores/onboarding' import { useOnboardingStore } from "@/stores/onboarding";
import { adminAPI } from '@/api/admin' import { adminAPI } from "@/api/admin";
import type { AdminGroup, GroupPlatform, SubscriptionType } from '@/types' import type { AdminGroup, GroupPlatform, SubscriptionType } from "@/types";
import type { Column } from '@/components/common/types' import type { Column } from "@/components/common/types";
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from "@/components/layout/AppLayout.vue";
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import DataTable from '@/components/common/DataTable.vue' import DataTable from "@/components/common/DataTable.vue";
import Pagination from '@/components/common/Pagination.vue' import Pagination from "@/components/common/Pagination.vue";
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from "@/components/common/BaseDialog.vue";
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from "@/components/common/EmptyState.vue";
import Select from '@/components/common/Select.vue' import Select from "@/components/common/Select.vue";
import PlatformIcon from '@/components/common/PlatformIcon.vue' import PlatformIcon from "@/components/common/PlatformIcon.vue";
import Icon from '@/components/icons/Icon.vue' import Icon from "@/components/icons/Icon.vue";
import GroupRateMultipliersModal from '@/components/admin/group/GroupRateMultipliersModal.vue' import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
import GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue' import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch' import { useKeyedDebouncedSearch } from "@/composables/useKeyedDebouncedSearch";
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from "@/composables/usePersistedPageSize";
import {
const { t } = useI18n() createDefaultMessagesDispatchFormState,
const appStore = useAppStore() messagesDispatchConfigToFormState,
const onboardingStore = useOnboardingStore() messagesDispatchFormStateToConfig,
resetMessagesDispatchFormState,
type MessagesDispatchMappingRow,
} from "./groupsMessagesDispatch";
const { t } = useI18n();
const appStore = useAppStore();
const onboardingStore = useOnboardingStore();
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true }, { key: "name", label: t("admin.groups.columns.name"), sortable: true },
{ key: 'platform', label: t('admin.groups.columns.platform'), sortable: true }, {
{ key: 'billing_type', label: t('admin.groups.columns.billingType'), sortable: true }, key: "platform",
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true }, label: t("admin.groups.columns.platform"),
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true }, 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: "billing_type",
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true }, label: t("admin.groups.columns.billingType"),
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false } 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 },
]);
// Filter options // Filter options
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: '', label: t('admin.groups.allStatus') }, { value: "", label: t("admin.groups.allStatus") },
{ value: 'active', label: t('admin.accounts.status.active') }, { value: "active", label: t("admin.accounts.status.active") },
{ value: 'inactive', label: t('admin.accounts.status.inactive') } { value: "inactive", label: t("admin.accounts.status.inactive") },
]) ]);
const exclusiveOptions = computed(() => [ const exclusiveOptions = computed(() => [
{ value: '', label: t('admin.groups.allGroups') }, { value: "", label: t("admin.groups.allGroups") },
{ value: 'true', label: t('admin.groups.exclusive') }, { value: "true", label: t("admin.groups.exclusive") },
{ value: 'false', label: t('admin.groups.nonExclusive') } { value: "false", label: t("admin.groups.nonExclusive") },
]) ]);
const platformOptions = computed(() => [ const platformOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' }, { value: "anthropic", label: "Anthropic" },
{ value: 'openai', label: 'OpenAI' }, { value: "openai", label: "OpenAI" },
{ value: 'gemini', label: 'Gemini' }, { value: "gemini", label: "Gemini" },
{ value: 'antigravity', label: 'Antigravity' } { value: "antigravity", label: "Antigravity" },
]) ]);
const platformFilterOptions = computed(() => [ const platformFilterOptions = computed(() => [
{ value: '', label: t('admin.groups.allPlatforms') }, { value: "", label: t("admin.groups.allPlatforms") },
{ value: 'anthropic', label: 'Anthropic' }, { value: "anthropic", label: "Anthropic" },
{ value: 'openai', label: 'OpenAI' }, { value: "openai", label: "OpenAI" },
{ value: 'gemini', label: 'Gemini' }, { value: "gemini", label: "Gemini" },
{ value: 'antigravity', label: 'Antigravity' } { value: "antigravity", label: "Antigravity" },
]) ]);
const editStatusOptions = computed(() => [ const editStatusOptions = computed(() => [
{ value: 'active', label: t('admin.accounts.status.active') }, { value: "active", label: t("admin.accounts.status.active") },
{ value: 'inactive', label: t('admin.accounts.status.inactive') } { value: "inactive", label: t("admin.accounts.status.inactive") },
]) ]);
const subscriptionTypeOptions = computed(() => [ const subscriptionTypeOptions = computed(() => [
{ value: 'standard', label: t('admin.groups.subscription.standard') }, { value: "standard", label: t("admin.groups.subscription.standard") },
{ value: 'subscription', label: t('admin.groups.subscription.subscription') } { value: "subscription", label: t("admin.groups.subscription.subscription") },
]) ]);
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组 // 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const fallbackGroupOptions = computed(() => { const fallbackGroupOptions = computed(() => {
const options: { value: number | null; label: string }[] = [ const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') } { value: null, label: t("admin.groups.claudeCode.noFallback") },
] ];
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active' (g) =>
) g.platform === "anthropic" &&
!g.claude_code_only &&
g.status === "active",
);
eligibleGroups.forEach((g) => { eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name }) options.push({ value: g.id, label: g.name });
}) });
return options return options;
}) });
// 降级分组选项(编辑时)- 排除自身 // 降级分组选项(编辑时)- 排除自身
const fallbackGroupOptionsForEdit = computed(() => { const fallbackGroupOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [ const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') } { value: null, label: t("admin.groups.claudeCode.noFallback") },
] ];
const currentId = editingGroup.value?.id const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active' && g.id !== currentId (g) =>
) g.platform === "anthropic" &&
!g.claude_code_only &&
g.status === "active" &&
g.id !== currentId,
);
eligibleGroups.forEach((g) => { eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name }) options.push({ value: g.id, label: g.name });
}) });
return options return options;
}) });
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组 // 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const invalidRequestFallbackOptions = computed(() => { const invalidRequestFallbackOptions = computed(() => {
const options: { value: number | null; label: string }[] = [ const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') } { value: null, label: t("admin.groups.invalidRequestFallback.noFallback") },
] ];
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => (g) =>
g.platform === 'anthropic' && g.platform === "anthropic" &&
g.status === 'active' && g.status === "active" &&
g.subscription_type !== 'subscription' && g.subscription_type !== "subscription" &&
g.fallback_group_id_on_invalid_request === null g.fallback_group_id_on_invalid_request === null,
) );
eligibleGroups.forEach((g) => { eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name }) options.push({ value: g.id, label: g.name });
}) });
return options return options;
}) });
// 无效请求兜底分组选项(编辑时)- 排除自身 // 无效请求兜底分组选项(编辑时)- 排除自身
const invalidRequestFallbackOptionsForEdit = computed(() => { const invalidRequestFallbackOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [ const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') } { value: null, label: t("admin.groups.invalidRequestFallback.noFallback") },
] ];
const currentId = editingGroup.value?.id const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => (g) =>
g.platform === 'anthropic' && g.platform === "anthropic" &&
g.status === 'active' && g.status === "active" &&
g.subscription_type !== 'subscription' && g.subscription_type !== "subscription" &&
g.fallback_group_id_on_invalid_request === null && g.fallback_group_id_on_invalid_request === null &&
g.id !== currentId g.id !== currentId,
) );
eligibleGroups.forEach((g) => { eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name }) options.push({ value: g.id, label: g.name });
}) });
return options return options;
}) });
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组 // 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => { const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => g.platform === createForm.platform && (g.account_count || 0) > 0 (g) => g.platform === createForm.platform && (g.account_count || 0) > 0,
) );
return eligibleGroups.map((g) => ({ return eligibleGroups.map((g) => ({
value: g.id, value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)` label: `${g.name} (${g.account_count || 0} 个账号)`,
})) }));
}) });
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身 // 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
const copyAccountsGroupOptionsForEdit = computed(() => { const copyAccountsGroupOptionsForEdit = computed(() => {
const currentId = editingGroup.value?.id const currentId = editingGroup.value?.id;
const eligibleGroups = groups.value.filter( const eligibleGroups = groups.value.filter(
(g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId (g) =>
) g.platform === editForm.platform &&
(g.account_count || 0) > 0 &&
g.id !== currentId,
);
return eligibleGroups.map((g) => ({ return eligibleGroups.map((g) => ({
value: g.id, value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)` label: `${g.name} (${g.account_count || 0} 个账号)`,
})) }));
}) });
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([]);
const loading = ref(false) const loading = ref(false);
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map()) const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(
const usageLoading = ref(false) new Map(),
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map()) );
const searchQuery = ref('') 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({ const filters = reactive({
platform: '', platform: "",
status: '', status: "",
is_exclusive: '' is_exclusive: "",
}) });
const pagination = reactive({ const pagination = reactive({
page: 1, page: 1,
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0, total: 0,
pages: 0 pages: 0,
}) });
let abortController: AbortController | null = null let abortController: AbortController | null = null;
const showCreateModal = ref(false) const showCreateModal = ref(false);
const showEditModal = ref(false) const showEditModal = ref(false);
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false);
const showSortModal = ref(false) const showSortModal = ref(false);
const submitting = ref(false) const submitting = ref(false);
const sortSubmitting = ref(false) const sortSubmitting = ref(false);
const editingGroup = ref<AdminGroup | null>(null) const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null) const deletingGroup = ref<AdminGroup | null>(null);
const showRateMultipliersModal = ref(false) const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null) const rateMultipliersGroup = ref<AdminGroup | null>(null);
const sortableGroups = ref<AdminGroup[]>([]) const sortableGroups = ref<AdminGroup[]>([]);
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const createForm = reactive({ const createForm = reactive({
name: '', name: "",
description: '', description: "",
platform: 'anthropic' as GroupPlatform, platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0, rate_multiplier: 1.0,
is_exclusive: false, is_exclusive: false,
subscription_type: 'standard' as SubscriptionType, subscription_type: "standard" as SubscriptionType,
daily_limit_usd: null as number | null, daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null, weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null, monthly_limit_usd: null as number | null,
...@@ -2018,68 +2961,89 @@ const createForm = reactive({ ...@@ -2018,68 +2961,89 @@ const createForm = reactive({
fallback_group_id_on_invalid_request: null as number | null, fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false, allow_messages_dispatch: false,
default_mapped_model: 'gpt-5.4', 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 平台) // 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only: false, require_oauth_only: false,
require_privacy_set: false, require_privacy_set: false,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false, model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台) // 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[], supported_model_scopes: ["claude", "gemini_text", "gemini_image"] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台) // MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true, mcp_xml_inject: true,
// 从分组复制账号 // 从分组复制账号
copy_accounts_from_group_ids: [] as number[] copy_accounts_from_group_ids: [] as number[],
}) });
// 简单账号类型(用于模型路由选择) // 简单账号类型(用于模型路由选择)
interface SimpleAccount { interface SimpleAccount {
id: number id: number;
name: string name: string;
} }
// 模型路由规则类型 // 模型路由规则类型
interface ModelRoutingRule { interface ModelRoutingRule {
pattern: string pattern: string;
accounts: SimpleAccount[] // 选中的账号对象数组 accounts: SimpleAccount[]; // 选中的账号对象数组
} }
// 创建表单的模型路由规则 // 创建表单的模型路由规则
const createModelRoutingRules = ref<ModelRoutingRule[]>([]) const createModelRoutingRules = ref<ModelRoutingRule[]>([]);
// 编辑表单的模型路由规则 // 编辑表单的模型路由规则
const editModelRoutingRules = ref<ModelRoutingRule[]>([]) const editModelRoutingRules = ref<ModelRoutingRule[]>([]);
// 规则对象稳定 key(避免使用 index 导致状态错位) // 规则对象稳定 key(避免使用 index 导致状态错位)
const resolveCreateRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('create-rule') const resolveCreateRuleKey =
const resolveEditRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('edit-rule') createStableObjectKeyResolver<ModelRoutingRule>("create-rule");
const resolveEditRuleKey =
const getCreateRuleRenderKey = (rule: ModelRoutingRule) => resolveCreateRuleKey(rule) createStableObjectKeyResolver<ModelRoutingRule>("edit-rule");
const getEditRuleRenderKey = (rule: ModelRoutingRule) => resolveEditRuleKey(rule) const resolveCreateMessagesDispatchRowKey =
createStableObjectKeyResolver<MessagesDispatchMappingRow>(
const getCreateRuleSearchKey = (rule: ModelRoutingRule) => `create-${resolveCreateRuleKey(rule)}` "create-messages-dispatch-row",
const getEditRuleSearchKey = (rule: ModelRoutingRule) => `edit-${resolveEditRuleKey(rule)}` );
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) => { const getRuleSearchKey = (rule: ModelRoutingRule, isEdit: boolean = false) => {
return isEdit ? getEditRuleSearchKey(rule) : getCreateRuleSearchKey(rule) return isEdit ? getEditRuleSearchKey(rule) : getCreateRuleSearchKey(rule);
} };
// 账号搜索相关状态 // 账号搜索相关状态
const accountSearchKeyword = ref<Record<string, string>>({}) const accountSearchKeyword = ref<Record<string, string>>({});
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({}) const accountSearchResults = ref<Record<string, SimpleAccount[]>>({});
const showAccountDropdown = ref<Record<string, boolean>>({}) const showAccountDropdown = ref<Record<string, boolean>>({});
const clearAccountSearchStateByKey = (key: string) => { const clearAccountSearchStateByKey = (key: string) => {
delete accountSearchKeyword.value[key] delete accountSearchKeyword.value[key];
delete accountSearchResults.value[key] delete accountSearchResults.value[key];
delete showAccountDropdown.value[key] delete showAccountDropdown.value[key];
} };
const clearAllAccountSearchState = () => { const clearAllAccountSearchState = () => {
accountSearchKeyword.value = {} accountSearchKeyword.value = {};
accountSearchResults.value = {} accountSearchResults.value = {};
showAccountDropdown.value = {} showAccountDropdown.value = {};
} };
const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({ const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
delay: 300, delay: 300,
...@@ -2089,163 +3053,181 @@ const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({ ...@@ -2089,163 +3053,181 @@ const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
20, 20,
{ {
search: keyword, search: keyword,
platform: 'anthropic' platform: "anthropic",
}, },
{ signal } { signal },
) );
return res.items.map((account) => ({ id: account.id, name: account.name })) return res.items.map((account) => ({ id: account.id, name: account.name }));
}, },
onSuccess: (key, result) => { onSuccess: (key, result) => {
accountSearchResults.value[key] = result accountSearchResults.value[key] = result;
}, },
onError: (key) => { onError: (key) => {
accountSearchResults.value[key] = [] accountSearchResults.value[key] = [];
} },
}) });
// 搜索账号(仅限 anthropic 平台) // 搜索账号(仅限 anthropic 平台)
const searchAccounts = (key: string) => { const searchAccounts = (key: string) => {
accountSearchRunner.trigger(key, accountSearchKeyword.value[key] || '') accountSearchRunner.trigger(key, accountSearchKeyword.value[key] || "");
} };
const searchAccountsByRule = (rule: ModelRoutingRule, isEdit: boolean = false) => { const searchAccountsByRule = (
searchAccounts(getRuleSearchKey(rule, isEdit)) rule: ModelRoutingRule,
} isEdit: boolean = false,
) => {
searchAccounts(getRuleSearchKey(rule, isEdit));
};
// 选择账号 // 选择账号
const selectAccount = (rule: ModelRoutingRule, account: SimpleAccount, isEdit: boolean = false) => { const selectAccount = (
if (!rule) return rule: ModelRoutingRule,
account: SimpleAccount,
isEdit: boolean = false,
) => {
if (!rule) return;
// 检查是否已选择 // 检查是否已选择
if (!rule.accounts.some(a => a.id === account.id)) { if (!rule.accounts.some((a) => a.id === account.id)) {
rule.accounts.push(account) rule.accounts.push(account);
} }
// 清空搜索 // 清空搜索
const key = getRuleSearchKey(rule, isEdit) const key = getRuleSearchKey(rule, isEdit);
accountSearchKeyword.value[key] = '' accountSearchKeyword.value[key] = "";
showAccountDropdown.value[key] = false showAccountDropdown.value[key] = false;
} };
// 移除已选账号 // 移除已选账号
const removeSelectedAccount = (rule: ModelRoutingRule, accountId: number, _isEdit: boolean = false) => { const removeSelectedAccount = (
if (!rule) return rule: ModelRoutingRule,
accountId: number,
_isEdit: boolean = false,
) => {
if (!rule) return;
rule.accounts = rule.accounts.filter(a => a.id !== accountId) rule.accounts = rule.accounts.filter((a) => a.id !== accountId);
} };
// 切换创建表单的模型系列选择 // 切换创建表单的模型系列选择
const toggleCreateScope = (scope: string) => { const toggleCreateScope = (scope: string) => {
const idx = createForm.supported_model_scopes.indexOf(scope) const idx = createForm.supported_model_scopes.indexOf(scope);
if (idx === -1) { if (idx === -1) {
createForm.supported_model_scopes.push(scope) createForm.supported_model_scopes.push(scope);
} else { } else {
createForm.supported_model_scopes.splice(idx, 1) createForm.supported_model_scopes.splice(idx, 1);
} }
} };
// 切换编辑表单的模型系列选择 // 切换编辑表单的模型系列选择
const toggleEditScope = (scope: string) => { const toggleEditScope = (scope: string) => {
const idx = editForm.supported_model_scopes.indexOf(scope) const idx = editForm.supported_model_scopes.indexOf(scope);
if (idx === -1) { if (idx === -1) {
editForm.supported_model_scopes.push(scope) editForm.supported_model_scopes.push(scope);
} else { } else {
editForm.supported_model_scopes.splice(idx, 1) editForm.supported_model_scopes.splice(idx, 1);
} }
} };
// 处理账号搜索输入框聚焦 // 处理账号搜索输入框聚焦
const onAccountSearchFocus = (rule: ModelRoutingRule, isEdit: boolean = false) => { const onAccountSearchFocus = (
const key = getRuleSearchKey(rule, isEdit) rule: ModelRoutingRule,
showAccountDropdown.value[key] = true isEdit: boolean = false,
) => {
const key = getRuleSearchKey(rule, isEdit);
showAccountDropdown.value[key] = true;
// 如果没有搜索结果,触发一次搜索 // 如果没有搜索结果,触发一次搜索
if (!accountSearchResults.value[key]?.length) { if (!accountSearchResults.value[key]?.length) {
searchAccounts(key) searchAccounts(key);
} }
} };
// 添加创建表单的路由规则 // 添加创建表单的路由规则
const addCreateRoutingRule = () => { const addCreateRoutingRule = () => {
createModelRoutingRules.value.push({ pattern: '', accounts: [] }) createModelRoutingRules.value.push({ pattern: "", accounts: [] });
} };
// 删除创建表单的路由规则 // 删除创建表单的路由规则
const removeCreateRoutingRule = (rule: ModelRoutingRule) => { const removeCreateRoutingRule = (rule: ModelRoutingRule) => {
const index = createModelRoutingRules.value.indexOf(rule) const index = createModelRoutingRules.value.indexOf(rule);
if (index === -1) return if (index === -1) return;
const key = getCreateRuleSearchKey(rule) const key = getCreateRuleSearchKey(rule);
accountSearchRunner.clearKey(key) accountSearchRunner.clearKey(key);
clearAccountSearchStateByKey(key) clearAccountSearchStateByKey(key);
createModelRoutingRules.value.splice(index, 1) createModelRoutingRules.value.splice(index, 1);
} };
// 添加编辑表单的路由规则 // 添加编辑表单的路由规则
const addEditRoutingRule = () => { const addEditRoutingRule = () => {
editModelRoutingRules.value.push({ pattern: '', accounts: [] }) editModelRoutingRules.value.push({ pattern: "", accounts: [] });
} };
// 删除编辑表单的路由规则 // 删除编辑表单的路由规则
const removeEditRoutingRule = (rule: ModelRoutingRule) => { const removeEditRoutingRule = (rule: ModelRoutingRule) => {
const index = editModelRoutingRules.value.indexOf(rule) const index = editModelRoutingRules.value.indexOf(rule);
if (index === -1) return if (index === -1) return;
const key = getEditRuleSearchKey(rule) const key = getEditRuleSearchKey(rule);
accountSearchRunner.clearKey(key) accountSearchRunner.clearKey(key);
clearAccountSearchStateByKey(key) clearAccountSearchStateByKey(key);
editModelRoutingRules.value.splice(index, 1) editModelRoutingRules.value.splice(index, 1);
} };
// 将 UI 格式的路由规则转换为 API 格式 // 将 UI 格式的路由规则转换为 API 格式
const convertRoutingRulesToApiFormat = (rules: ModelRoutingRule[]): Record<string, number[]> | null => { const convertRoutingRulesToApiFormat = (
const result: Record<string, number[]> = {} rules: ModelRoutingRule[],
let hasValidRules = false ): Record<string, number[]> | null => {
const result: Record<string, number[]> = {};
let hasValidRules = false;
for (const rule of rules) { for (const rule of rules) {
const pattern = rule.pattern.trim() const pattern = rule.pattern.trim();
if (!pattern) continue if (!pattern) continue;
const accountIds = rule.accounts.map(a => a.id).filter(id => id > 0) const accountIds = rule.accounts.map((a) => a.id).filter((id) => id > 0);
if (accountIds.length > 0) { if (accountIds.length > 0) {
result[pattern] = accountIds result[pattern] = accountIds;
hasValidRules = true hasValidRules = true;
} }
} }
return hasValidRules ? result : null return hasValidRules ? result : null;
} };
// 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称) // 将 API 格式的路由规则转换为 UI 格式(需要加载账号名称)
const convertApiFormatToRoutingRules = async (apiFormat: Record<string, number[]> | null): Promise<ModelRoutingRule[]> => { const convertApiFormatToRoutingRules = async (
if (!apiFormat) return [] apiFormat: Record<string, number[]> | null,
): Promise<ModelRoutingRule[]> => {
if (!apiFormat) return [];
const rules: ModelRoutingRule[] = [] const rules: ModelRoutingRule[] = [];
for (const [pattern, accountIds] of Object.entries(apiFormat)) { for (const [pattern, accountIds] of Object.entries(apiFormat)) {
// 加载账号信息 // 加载账号信息
const accounts: SimpleAccount[] = [] const accounts: SimpleAccount[] = [];
for (const id of accountIds) { for (const id of accountIds) {
try { try {
const account = await adminAPI.accounts.getById(id) const account = await adminAPI.accounts.getById(id);
accounts.push({ id: account.id, name: account.name }) accounts.push({ id: account.id, name: account.name });
} catch { } catch {
// 如果账号不存在,仍然显示 ID // 如果账号不存在,仍然显示 ID
accounts.push({ id, name: `#${id}` }) accounts.push({ id, name: `#${id}` });
} }
} }
rules.push({ pattern, accounts }) rules.push({ pattern, accounts });
} }
return rules return rules;
} };
const editForm = reactive({ const editForm = reactive({
name: '', name: "",
description: '', description: "",
platform: 'anthropic' as GroupPlatform, platform: "anthropic" as GroupPlatform,
rate_multiplier: 1.0, rate_multiplier: 1.0,
is_exclusive: false, is_exclusive: false,
status: 'active' as 'active' | 'inactive', status: "active" as "active" | "inactive",
subscription_type: 'standard' as SubscriptionType, subscription_type: "standard" as SubscriptionType,
daily_limit_usd: null as number | null, daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null, weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null, monthly_limit_usd: null as number | null,
...@@ -2259,92 +3241,121 @@ const editForm = reactive({ ...@@ -2259,92 +3241,121 @@ const editForm = reactive({
fallback_group_id_on_invalid_request: null as number | null, fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false, allow_messages_dispatch: 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 平台) // 账号过滤控制(OpenAI/Antigravity 平台)
require_oauth_only: false, require_oauth_only: false,
require_privacy_set: false, require_privacy_set: false,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false, model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台) // 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[], supported_model_scopes: ["claude", "gemini_text", "gemini_image"] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台) // MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true, mcp_xml_inject: true,
// 从分组复制账号 // 从分组复制账号
copy_accounts_from_group_ids: [] as number[] copy_accounts_from_group_ids: [] as number[],
}) });
// 根据分组类型返回不同的删除确认消息 // 根据分组类型返回不同的删除确认消息
const deleteConfirmMessage = computed(() => { const deleteConfirmMessage = computed(() => {
if (!deletingGroup.value) { if (!deletingGroup.value) {
return '' return "";
} }
if (deletingGroup.value.subscription_type === 'subscription') { if (deletingGroup.value.subscription_type === "subscription") {
return t('admin.groups.deleteConfirmSubscription', { name: deletingGroup.value.name }) return t("admin.groups.deleteConfirmSubscription", {
name: deletingGroup.value.name,
});
} }
return t('admin.groups.deleteConfirm', { name: deletingGroup.value.name }) return t("admin.groups.deleteConfirm", { name: deletingGroup.value.name });
}) });
const loadGroups = async () => { const loadGroups = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort();
} }
const currentController = new AbortController() const currentController = new AbortController();
abortController = currentController abortController = currentController;
const { signal } = currentController const { signal } = currentController;
loading.value = true loading.value = true;
try { try {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { const response = await adminAPI.groups.list(
platform: (filters.platform as GroupPlatform) || undefined, pagination.page,
status: filters.status as any, pagination.page_size,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined, {
search: searchQuery.value.trim() || undefined platform: (filters.platform as GroupPlatform) || undefined,
}, { signal }) status: filters.status as any,
if (signal.aborted) return is_exclusive: filters.is_exclusive
groups.value = response.items ? filters.is_exclusive === "true"
pagination.total = response.total : undefined,
pagination.pages = response.pages search: searchQuery.value.trim() || undefined,
loadUsageSummary() },
loadCapacitySummary() { signal },
);
if (signal.aborted) return;
groups.value = response.items;
pagination.total = response.total;
pagination.pages = response.pages;
loadUsageSummary();
loadCapacitySummary();
} catch (error: any) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { if (
return signal.aborted ||
error?.name === "AbortError" ||
error?.code === "ERR_CANCELED"
) {
return;
} }
appStore.showError(t('admin.groups.failedToLoad')) appStore.showError(t("admin.groups.failedToLoad"));
console.error('Error loading groups:', error) console.error("Error loading groups:", error);
} finally { } finally {
if (abortController === currentController && !signal.aborted) { if (abortController === currentController && !signal.aborted) {
loading.value = false loading.value = false;
} }
} }
} };
const formatCost = (cost: number): string => { const formatCost = (cost: number): string => {
if (cost >= 1000) return cost.toFixed(0) if (cost >= 1000) return cost.toFixed(0);
if (cost >= 100) return cost.toFixed(1) if (cost >= 100) return cost.toFixed(1);
return cost.toFixed(2) return cost.toFixed(2);
} };
const loadUsageSummary = async () => { const loadUsageSummary = async () => {
usageLoading.value = true usageLoading.value = true;
try { try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const data = await adminAPI.groups.getUsageSummary(tz) const data = await adminAPI.groups.getUsageSummary(tz);
const map = new Map<number, { today_cost: number; total_cost: number }>() const map = new Map<number, { today_cost: number; total_cost: number }>();
for (const item of data) { for (const item of data) {
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost }) map.set(item.group_id, {
today_cost: item.today_cost,
total_cost: item.total_cost,
});
} }
usageMap.value = map usageMap.value = map;
} catch (error) { } catch (error) {
console.error('Error loading group usage summary:', error) console.error("Error loading group usage summary:", error);
} finally { } finally {
usageLoading.value = false usageLoading.value = false;
} }
} };
const loadCapacitySummary = async () => { const loadCapacitySummary = async () => {
try { try {
const data = await adminAPI.groups.getCapacitySummary() const data = await adminAPI.groups.getCapacitySummary();
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>() const map = new Map<
number,
{
concurrencyUsed: number;
concurrencyMax: number;
sessionsUsed: number;
sessionsMax: number;
rpmUsed: number;
rpmMax: number;
}
>();
for (const item of data) { for (const item of data) {
map.set(item.group_id, { map.set(item.group_id, {
concurrencyUsed: item.concurrency_used, concurrencyUsed: item.concurrency_used,
...@@ -2352,313 +3363,417 @@ const loadCapacitySummary = async () => { ...@@ -2352,313 +3363,417 @@ const loadCapacitySummary = async () => {
sessionsUsed: item.sessions_used, sessionsUsed: item.sessions_used,
sessionsMax: item.sessions_max, sessionsMax: item.sessions_max,
rpmUsed: item.rpm_used, rpmUsed: item.rpm_used,
rpmMax: item.rpm_max rpmMax: item.rpm_max,
}) });
} }
capacityMap.value = map capacityMap.value = map;
} catch (error) { } catch (error) {
console.error('Error loading group capacity summary:', error) console.error("Error loading group capacity summary:", error);
} }
} };
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>;
const handleSearch = () => { const handleSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
pagination.page = 1 pagination.page = 1;
loadGroups() loadGroups();
}, 300) }, 300);
} };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.page = page pagination.page = page;
loadGroups() loadGroups();
} };
const handlePageSizeChange = (pageSize: number) => { const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize pagination.page_size = pageSize;
pagination.page = 1 pagination.page = 1;
loadGroups() loadGroups();
} };
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false;
createModelRoutingRules.value.forEach((rule) => { createModelRoutingRules.value.forEach((rule) => {
accountSearchRunner.clearKey(getCreateRuleSearchKey(rule)) accountSearchRunner.clearKey(getCreateRuleSearchKey(rule));
}) });
clearAllAccountSearchState() clearAllAccountSearchState();
createForm.name = '' createForm.name = "";
createForm.description = '' createForm.description = "";
createForm.platform = 'anthropic' createForm.platform = "anthropic";
createForm.rate_multiplier = 1.0 createForm.rate_multiplier = 1.0;
createForm.is_exclusive = false createForm.is_exclusive = false;
createForm.subscription_type = 'standard' createForm.subscription_type = "standard";
createForm.daily_limit_usd = null createForm.daily_limit_usd = null;
createForm.weekly_limit_usd = null createForm.weekly_limit_usd = null;
createForm.monthly_limit_usd = null createForm.monthly_limit_usd = null;
createForm.image_price_1k = null createForm.image_price_1k = null;
createForm.image_price_2k = null createForm.image_price_2k = null;
createForm.image_price_4k = null createForm.image_price_4k = null;
createForm.claude_code_only = false createForm.claude_code_only = false;
createForm.fallback_group_id = null createForm.fallback_group_id = null;
createForm.fallback_group_id_on_invalid_request = null createForm.fallback_group_id_on_invalid_request = null;
createForm.allow_messages_dispatch = false resetMessagesDispatchFormState(createForm);
createForm.require_oauth_only = false createForm.require_oauth_only = false;
createForm.require_privacy_set = false createForm.require_privacy_set = false;
createForm.default_mapped_model = 'gpt-5.4' createForm.supported_model_scopes = ["claude", "gemini_text", "gemini_image"];
createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image'] createForm.mcp_xml_inject = true;
createForm.mcp_xml_inject = true createForm.copy_accounts_from_group_ids = [];
createForm.copy_accounts_from_group_ids = [] createModelRoutingRules.value = [];
createModelRoutingRules.value = [] };
}
const normalizeOptionalLimit = (
const normalizeOptionalLimit = (value: number | string | null | undefined): number | null => { value: number | string | null | undefined,
): number | null => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return null return null;
} }
if (typeof value === 'string') { if (typeof value === "string") {
const trimmed = value.trim() const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
return null return null;
} }
const parsed = Number(trimmed) const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} }
return Number.isFinite(value) && value > 0 ? value : null return Number.isFinite(value) && value > 0 ? value : null;
} };
const handleCreateGroup = async () => { const handleCreateGroup = async () => {
if (!createForm.name.trim()) { if (!createForm.name.trim()) {
appStore.showError(t('admin.groups.nameRequired')) appStore.showError(t("admin.groups.nameRequired"));
return return;
} }
submitting.value = true submitting.value = true;
try { try {
// 构建请求数据,包含模型路由配置 // 构建请求数据,包含模型路由配置
const requestData = { const requestData = {
...createForm, ...createForm,
daily_limit_usd: normalizeOptionalLimit(createForm.daily_limit_usd as number | string | null), daily_limit_usd: normalizeOptionalLimit(
weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null), createForm.daily_limit_usd as number | string | null,
monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null), ),
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value) weekly_limit_usd: normalizeOptionalLimit(
} createForm.weekly_limit_usd as number | string | null,
),
monthly_limit_usd: normalizeOptionalLimit(
createForm.monthly_limit_usd as number | string | null,
),
model_routing: convertRoutingRulesToApiFormat(
createModelRoutingRules.value,
),
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,
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => v === '' ? null : v const emptyToNull = (v: any) => (v === "" ? null : v);
requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd) requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd);
requestData.weekly_limit_usd = emptyToNull(requestData.weekly_limit_usd) requestData.weekly_limit_usd = emptyToNull(requestData.weekly_limit_usd);
requestData.monthly_limit_usd = emptyToNull(requestData.monthly_limit_usd) requestData.monthly_limit_usd = emptyToNull(requestData.monthly_limit_usd);
await adminAPI.groups.create(requestData) await adminAPI.groups.create(requestData);
appStore.showSuccess(t('admin.groups.groupCreated')) appStore.showSuccess(t("admin.groups.groupCreated"));
closeCreateModal() closeCreateModal();
loadGroups() loadGroups();
// Only advance tour if active, on submit step, and creation succeeded // Only advance tour if active, on submit step, and creation succeeded
if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) { if (onboardingStore.isCurrentStep('[data-tour="group-form-submit"]')) {
onboardingStore.nextStep(500) onboardingStore.nextStep(500);
} }
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate')) appStore.showError(
console.error('Error creating group:', error) error.response?.data?.detail || t("admin.groups.failedToCreate"),
);
console.error("Error creating group:", error);
// Don't advance tour on error // Don't advance tour on error
} finally { } finally {
submitting.value = false submitting.value = false;
} }
} };
const handleEdit = async (group: AdminGroup) => { const handleEdit = async (group: AdminGroup) => {
editingGroup.value = group editingGroup.value = group;
editForm.name = group.name editForm.name = group.name;
editForm.description = group.description || '' editForm.description = group.description || "";
editForm.platform = group.platform editForm.platform = group.platform;
editForm.rate_multiplier = group.rate_multiplier editForm.rate_multiplier = group.rate_multiplier;
editForm.is_exclusive = group.is_exclusive editForm.is_exclusive = group.is_exclusive;
editForm.status = group.status editForm.status = group.status;
editForm.subscription_type = group.subscription_type || 'standard' editForm.subscription_type = group.subscription_type || "standard";
editForm.daily_limit_usd = group.daily_limit_usd editForm.daily_limit_usd = group.daily_limit_usd;
editForm.weekly_limit_usd = group.weekly_limit_usd editForm.weekly_limit_usd = group.weekly_limit_usd;
editForm.monthly_limit_usd = group.monthly_limit_usd editForm.monthly_limit_usd = group.monthly_limit_usd;
editForm.image_price_1k = group.image_price_1k editForm.image_price_1k = group.image_price_1k;
editForm.image_price_2k = group.image_price_2k editForm.image_price_2k = group.image_price_2k;
editForm.image_price_4k = group.image_price_4k editForm.image_price_4k = group.image_price_4k;
editForm.claude_code_only = group.claude_code_only || false editForm.claude_code_only = group.claude_code_only || false;
editForm.fallback_group_id = group.fallback_group_id editForm.fallback_group_id = group.fallback_group_id;
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request editForm.fallback_group_id_on_invalid_request =
editForm.allow_messages_dispatch = group.allow_messages_dispatch || false group.fallback_group_id_on_invalid_request;
editForm.require_oauth_only = group.require_oauth_only ?? false const messagesDispatchFormState = messagesDispatchConfigToFormState(
editForm.require_privacy_set = group.require_privacy_set ?? false group.messages_dispatch_model_config,
editForm.default_mapped_model = group.default_mapped_model || '' );
editForm.model_routing_enabled = group.model_routing_enabled || false editForm.allow_messages_dispatch =
editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image'] group.allow_messages_dispatch ||
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true messagesDispatchFormState.allow_messages_dispatch;
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空 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 = []; // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称) // 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing) editModelRoutingRules.value = await convertApiFormatToRoutingRules(
showEditModal.value = true group.model_routing,
} );
showEditModal.value = true;
};
const closeEditModal = () => { const closeEditModal = () => {
editModelRoutingRules.value.forEach((rule) => { editModelRoutingRules.value.forEach((rule) => {
accountSearchRunner.clearKey(getEditRuleSearchKey(rule)) accountSearchRunner.clearKey(getEditRuleSearchKey(rule));
}) });
clearAllAccountSearchState() clearAllAccountSearchState();
showEditModal.value = false showEditModal.value = false;
editingGroup.value = null editingGroup.value = null;
editModelRoutingRules.value = [] editModelRoutingRules.value = [];
editForm.copy_accounts_from_group_ids = [] editForm.copy_accounts_from_group_ids = [];
} resetMessagesDispatchFormState(editForm);
};
const handleUpdateGroup = async () => { const handleUpdateGroup = async () => {
if (!editingGroup.value) return if (!editingGroup.value) return;
if (!editForm.name.trim()) { if (!editForm.name.trim()) {
appStore.showError(t('admin.groups.nameRequired')) appStore.showError(t("admin.groups.nameRequired"));
return return;
} }
submitting.value = true submitting.value = true;
try { try {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除) // 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = { const payload = {
...editForm, ...editForm,
daily_limit_usd: normalizeOptionalLimit(editForm.daily_limit_usd as number | string | null), daily_limit_usd: normalizeOptionalLimit(
weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null), editForm.daily_limit_usd as number | string | null,
monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null), ),
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id, weekly_limit_usd: normalizeOptionalLimit(
editForm.weekly_limit_usd as number | string | null,
),
monthly_limit_usd: normalizeOptionalLimit(
editForm.monthly_limit_usd as number | string | null,
),
fallback_group_id:
editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request: fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null editForm.fallback_group_id_on_invalid_request === null
? 0 ? 0
: editForm.fallback_group_id_on_invalid_request, : editForm.fallback_group_id_on_invalid_request,
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value) model_routing: convertRoutingRulesToApiFormat(
} editModelRoutingRules.value,
),
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,
};
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const emptyToNull = (v: any) => v === '' ? null : v const emptyToNull = (v: any) => (v === "" ? null : v);
payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd) payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd);
payload.weekly_limit_usd = emptyToNull(payload.weekly_limit_usd) payload.weekly_limit_usd = emptyToNull(payload.weekly_limit_usd);
payload.monthly_limit_usd = emptyToNull(payload.monthly_limit_usd) payload.monthly_limit_usd = emptyToNull(payload.monthly_limit_usd);
await adminAPI.groups.update(editingGroup.value.id, payload) await adminAPI.groups.update(editingGroup.value.id, payload);
appStore.showSuccess(t('admin.groups.groupUpdated')) appStore.showSuccess(t("admin.groups.groupUpdated"));
closeEditModal() closeEditModal();
loadGroups() loadGroups();
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdate')) appStore.showError(
console.error('Error updating group:', error) error.response?.data?.detail || t("admin.groups.failedToUpdate"),
);
console.error("Error updating group:", error);
} finally { } finally {
submitting.value = false 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) => { const handleRateMultipliers = (group: AdminGroup) => {
rateMultipliersGroup.value = group rateMultipliersGroup.value = group;
showRateMultipliersModal.value = true showRateMultipliersModal.value = true;
} };
const handleDelete = (group: AdminGroup) => { const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group deletingGroup.value = group;
showDeleteDialog.value = true showDeleteDialog.value = true;
} };
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deletingGroup.value) return if (!deletingGroup.value) return;
try { try {
await adminAPI.groups.delete(deletingGroup.value.id) await adminAPI.groups.delete(deletingGroup.value.id);
appStore.showSuccess(t('admin.groups.groupDeleted')) appStore.showSuccess(t("admin.groups.groupDeleted"));
showDeleteDialog.value = false showDeleteDialog.value = false;
deletingGroup.value = null deletingGroup.value = null;
loadGroups() loadGroups();
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToDelete')) appStore.showError(
console.error('Error deleting group:', error) error.response?.data?.detail || t("admin.groups.failedToDelete"),
);
console.error("Error deleting group:", error);
} }
} };
// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true // 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true
watch( watch(
() => createForm.subscription_type, () => createForm.subscription_type,
(newVal) => { (newVal) => {
if (newVal === 'subscription') { if (newVal === "subscription") {
createForm.is_exclusive = true createForm.is_exclusive = true;
createForm.fallback_group_id_on_invalid_request = null createForm.fallback_group_id_on_invalid_request = null;
} }
} },
) );
watch( watch(
() => createForm.platform, () => createForm.platform,
(newVal) => { (newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) { if (!["anthropic", "antigravity"].includes(newVal)) {
createForm.fallback_group_id_on_invalid_request = null createForm.fallback_group_id_on_invalid_request = null;
} }
if (newVal !== 'openai') { if (newVal !== "openai") {
createForm.allow_messages_dispatch = false resetMessagesDispatchFormState(createForm);
createForm.default_mapped_model = ''
} }
if (!['openai', 'antigravity', 'anthropic', 'gemini'].includes(newVal)) { if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
createForm.require_oauth_only = false createForm.require_oauth_only = false;
createForm.require_privacy_set = false createForm.require_privacy_set = false;
} }
} },
) );
watch(
() => editForm.platform,
(newVal) => {
if (!["anthropic", "antigravity"].includes(newVal)) {
editForm.fallback_group_id_on_invalid_request = null;
}
if (newVal !== "openai") {
resetMessagesDispatchFormState(editForm);
}
if (!["openai", "antigravity", "anthropic", "gemini"].includes(newVal)) {
editForm.require_oauth_only = false;
editForm.require_privacy_set = false;
}
},
);
// 点击外部关闭账号搜索下拉框 // 点击外部关闭账号搜索下拉框
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement const target = event.target as HTMLElement;
// 检查是否点击在下拉框或输入框内 // 检查是否点击在下拉框或输入框内
if (!target.closest('.account-search-container')) { if (!target.closest(".account-search-container")) {
Object.keys(showAccountDropdown.value).forEach(key => { Object.keys(showAccountDropdown.value).forEach((key) => {
showAccountDropdown.value[key] = false showAccountDropdown.value[key] = false;
}) });
} }
} };
// 打开排序弹窗 // 打开排序弹窗
const openSortModal = async () => { const openSortModal = async () => {
try { try {
// 获取所有分组(不分页) // 获取所有分组(不分页)
const allGroups = await adminAPI.groups.getAll() const allGroups = await adminAPI.groups.getAll();
// 按 sort_order 排序 // 按 sort_order 排序
sortableGroups.value = [...allGroups].sort((a, b) => a.sort_order - b.sort_order) sortableGroups.value = [...allGroups].sort(
showSortModal.value = true (a, b) => a.sort_order - b.sort_order,
);
showSortModal.value = true;
} catch (error) { } catch (error) {
appStore.showError(t('admin.groups.failedToLoad')) appStore.showError(t("admin.groups.failedToLoad"));
console.error('Error loading groups for sorting:', error) console.error("Error loading groups for sorting:", error);
} }
} };
// 关闭排序弹窗 // 关闭排序弹窗
const closeSortModal = () => { const closeSortModal = () => {
showSortModal.value = false showSortModal.value = false;
sortableGroups.value = [] sortableGroups.value = [];
} };
// 保存排序 // 保存排序
const saveSortOrder = async () => { const saveSortOrder = async () => {
sortSubmitting.value = true sortSubmitting.value = true;
try { try {
const updates = sortableGroups.value.map((g, index) => ({ const updates = sortableGroups.value.map((g, index) => ({
id: g.id, id: g.id,
sort_order: index * 10 sort_order: index * 10,
})) }));
await adminAPI.groups.updateSortOrder(updates) await adminAPI.groups.updateSortOrder(updates);
appStore.showSuccess(t('admin.groups.sortOrderUpdated')) appStore.showSuccess(t("admin.groups.sortOrderUpdated"));
closeSortModal() closeSortModal();
loadGroups() loadGroups();
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdateSortOrder')) appStore.showError(
console.error('Error updating sort order:', error) error.response?.data?.detail || t("admin.groups.failedToUpdateSortOrder"),
);
console.error("Error updating sort order:", error);
} finally { } finally {
sortSubmitting.value = false sortSubmitting.value = false;
} }
} };
onMounted(() => { onMounted(() => {
loadGroups() loadGroups();
document.addEventListener('click', handleClickOutside) document.addEventListener("click", handleClickOutside);
}) });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener("click", handleClickOutside);
accountSearchRunner.clearAll() accountSearchRunner.clearAll();
clearAllAccountSearchState() clearAllAccountSearchState();
}) });
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment