mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 09:10:20 +08:00
Add Gemini model presets to BulkEditAccountModal for bulk model mapping. Fix AccountStatusIndicator model rate limit grid layout using proper grid container.
1081 lines
36 KiB
Vue
1081 lines
36 KiB
Vue
<template>
|
||
<BaseDialog
|
||
:show="show"
|
||
:title="t('admin.accounts.bulkEdit.title')"
|
||
width="wide"
|
||
@close="handleClose"
|
||
>
|
||
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
|
||
<!-- Info -->
|
||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.bulkEdit.selectionInfo', { count: accountIds.length }) }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Base URL (API Key only) -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-base-url-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-base-url-enabled"
|
||
>
|
||
{{ t('admin.accounts.baseUrl') }}
|
||
</label>
|
||
<input
|
||
v-model="enableBaseUrl"
|
||
id="bulk-edit-base-url-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-base-url"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<input
|
||
v-model="baseUrl"
|
||
id="bulk-edit-base-url"
|
||
type="text"
|
||
:disabled="!enableBaseUrl"
|
||
class="input"
|
||
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
|
||
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||
aria-labelledby="bulk-edit-base-url-label"
|
||
/>
|
||
<p class="input-hint">
|
||
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Model restriction -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-model-restriction-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-model-restriction-enabled"
|
||
>
|
||
{{ t('admin.accounts.modelRestriction') }}
|
||
</label>
|
||
<input
|
||
v-model="enableModelRestriction"
|
||
id="bulk-edit-model-restriction-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-model-restriction-body"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
id="bulk-edit-model-restriction-body"
|
||
:class="!enableModelRestriction && 'pointer-events-none opacity-50'"
|
||
role="group"
|
||
aria-labelledby="bulk-edit-model-restriction-label"
|
||
>
|
||
<!-- Mode Toggle -->
|
||
<div class="mb-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'whitelist'
|
||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
@click="modelRestrictionMode = 'whitelist'"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelWhitelist') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="[
|
||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||
modelRestrictionMode === 'mapping'
|
||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
@click="modelRestrictionMode = 'mapping'"
|
||
>
|
||
<svg
|
||
class="mr-1.5 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.modelMapping') }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Whitelist Mode -->
|
||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.selectAllowedModels') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Model Checkbox List -->
|
||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||
<label
|
||
v-for="model in allModels"
|
||
:key="model.value"
|
||
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||
:class="
|
||
allowedModels.includes(model.value)
|
||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||
: 'border-gray-200'
|
||
"
|
||
>
|
||
<input
|
||
v-model="allowedModels"
|
||
type="checkbox"
|
||
:value="model.value"
|
||
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||
<span v-if="allowedModels.length === 0">{{
|
||
t('admin.accounts.supportsAllModels')
|
||
}}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Mapping Mode -->
|
||
<div v-else>
|
||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.mapRequestModels') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Model Mapping List -->
|
||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||
<div
|
||
v-for="(mapping, index) in modelMappings"
|
||
:key="index"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<input
|
||
v-model="mapping.from"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.requestModel')"
|
||
/>
|
||
<svg
|
||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||
/>
|
||
</svg>
|
||
<input
|
||
v-model="mapping.to"
|
||
type="text"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.actualModel')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||
@click="removeModelMapping(index)"
|
||
>
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||
@click="addModelMapping"
|
||
>
|
||
<svg
|
||
class="mr-1 inline h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
{{ t('admin.accounts.addMapping') }}
|
||
</button>
|
||
|
||
<!-- Quick Add Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="preset in presetMappings"
|
||
:key="preset.label"
|
||
type="button"
|
||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||
@click="addPresetMapping(preset.from, preset.to)"
|
||
>
|
||
+ {{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom error codes -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<label
|
||
id="bulk-edit-custom-error-codes-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-custom-error-codes-enabled"
|
||
>
|
||
{{ t('admin.accounts.customErrorCodes') }}
|
||
</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.customErrorCodesHint') }}
|
||
</p>
|
||
</div>
|
||
<input
|
||
v-model="enableCustomErrorCodes"
|
||
id="bulk-edit-custom-error-codes-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-custom-error-codes-body"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3">
|
||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Error Code Buttons -->
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
v-for="code in commonErrorCodes"
|
||
:key="code.value"
|
||
type="button"
|
||
:class="[
|
||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||
selectedErrorCodes.includes(code.value)
|
||
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||
]"
|
||
@click="toggleErrorCode(code.value)"
|
||
>
|
||
{{ code.value }} {{ code.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Manual input -->
|
||
<div class="flex items-center gap-2">
|
||
<input
|
||
v-model="customErrorCodeInput"
|
||
id="bulk-edit-custom-error-code-input"
|
||
type="number"
|
||
min="100"
|
||
max="599"
|
||
class="input flex-1"
|
||
:placeholder="t('admin.accounts.enterErrorCode')"
|
||
aria-labelledby="bulk-edit-custom-error-codes-label"
|
||
@keyup.enter="addCustomErrorCode"
|
||
/>
|
||
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
|
||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 4v16m8-8H4"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Selected codes summary -->
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<span
|
||
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||
:key="code"
|
||
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
{{ code }}
|
||
<button
|
||
type="button"
|
||
class="hover:text-red-900 dark:hover:text-red-300"
|
||
@click="removeErrorCode(code)"
|
||
>
|
||
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
|
||
</button>
|
||
</span>
|
||
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||
{{ t('admin.accounts.noneSelectedUsesDefault') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intercept warmup requests (Anthropic only) -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex-1 pr-4">
|
||
<label
|
||
id="bulk-edit-intercept-warmup-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-intercept-warmup-enabled"
|
||
>
|
||
{{ t('admin.accounts.interceptWarmupRequests') }}
|
||
</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||
</p>
|
||
</div>
|
||
<input
|
||
v-model="enableInterceptWarmup"
|
||
id="bulk-edit-intercept-warmup-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-intercept-warmup-body"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<div v-if="enableInterceptWarmup" id="bulk-edit-intercept-warmup-body" class="mt-3">
|
||
<button
|
||
type="button"
|
||
:class="[
|
||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||
]"
|
||
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||
>
|
||
<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',
|
||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||
]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Proxy -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-proxy-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-proxy-enabled"
|
||
>
|
||
{{ t('admin.accounts.proxy') }}
|
||
</label>
|
||
<input
|
||
v-model="enableProxy"
|
||
id="bulk-edit-proxy-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-proxy-body"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<div id="bulk-edit-proxy-body" :class="!enableProxy && 'pointer-events-none opacity-50'">
|
||
<ProxySelector
|
||
v-model="proxyId"
|
||
:proxies="proxies"
|
||
aria-labelledby="bulk-edit-proxy-label"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Concurrency & Priority -->
|
||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-3">
|
||
<div>
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-concurrency-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-concurrency-enabled"
|
||
>
|
||
{{ t('admin.accounts.concurrency') }}
|
||
</label>
|
||
<input
|
||
v-model="enableConcurrency"
|
||
id="bulk-edit-concurrency-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-concurrency"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<input
|
||
v-model.number="concurrency"
|
||
id="bulk-edit-concurrency"
|
||
type="number"
|
||
min="1"
|
||
:disabled="!enableConcurrency"
|
||
class="input"
|
||
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
|
||
aria-labelledby="bulk-edit-concurrency-label"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-priority-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-priority-enabled"
|
||
>
|
||
{{ t('admin.accounts.priority') }}
|
||
</label>
|
||
<input
|
||
v-model="enablePriority"
|
||
id="bulk-edit-priority-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-priority"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<input
|
||
v-model.number="priority"
|
||
id="bulk-edit-priority"
|
||
type="number"
|
||
min="1"
|
||
:disabled="!enablePriority"
|
||
class="input"
|
||
:class="!enablePriority && 'cursor-not-allowed opacity-50'"
|
||
aria-labelledby="bulk-edit-priority-label"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-rate-multiplier-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-rate-multiplier-enabled"
|
||
>
|
||
{{ t('admin.accounts.billingRateMultiplier') }}
|
||
</label>
|
||
<input
|
||
v-model="enableRateMultiplier"
|
||
id="bulk-edit-rate-multiplier-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-rate-multiplier"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<input
|
||
v-model.number="rateMultiplier"
|
||
id="bulk-edit-rate-multiplier"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
:disabled="!enableRateMultiplier"
|
||
class="input"
|
||
:class="!enableRateMultiplier && 'cursor-not-allowed opacity-50'"
|
||
aria-labelledby="bulk-edit-rate-multiplier-label"
|
||
/>
|
||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-status-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-status-enabled"
|
||
>
|
||
{{ t('common.status') }}
|
||
</label>
|
||
<input
|
||
v-model="enableStatus"
|
||
id="bulk-edit-status-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-status"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<div id="bulk-edit-status" :class="!enableStatus && 'pointer-events-none opacity-50'">
|
||
<Select
|
||
v-model="status"
|
||
:options="statusOptions"
|
||
aria-labelledby="bulk-edit-status-label"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Groups -->
|
||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<label
|
||
id="bulk-edit-groups-label"
|
||
class="input-label mb-0"
|
||
for="bulk-edit-groups-enabled"
|
||
>
|
||
{{ t('nav.groups') }}
|
||
</label>
|
||
<input
|
||
v-model="enableGroups"
|
||
id="bulk-edit-groups-enabled"
|
||
type="checkbox"
|
||
aria-controls="bulk-edit-groups"
|
||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</div>
|
||
<div id="bulk-edit-groups" :class="!enableGroups && 'pointer-events-none opacity-50'">
|
||
<GroupSelector
|
||
v-model="groupIds"
|
||
:groups="groups"
|
||
aria-labelledby="bulk-edit-groups-label"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
<template #footer>
|
||
<div class="flex justify-end gap-3">
|
||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||
{{ t('common.cancel') }}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
form="bulk-edit-account-form"
|
||
:disabled="submitting"
|
||
class="btn btn-primary"
|
||
>
|
||
<svg
|
||
v-if="submitting"
|
||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
class="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="4"
|
||
/>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
/>
|
||
</svg>
|
||
{{
|
||
submitting ? t('admin.accounts.bulkEdit.updating') : t('admin.accounts.bulkEdit.submit')
|
||
}}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</BaseDialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { adminAPI } from '@/api/admin'
|
||
import type { Proxy, AdminGroup } from '@/types'
|
||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||
import Select from '@/components/common/Select.vue'
|
||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
import { buildModelMappingObject as buildModelMappingPayload } from '@/composables/useModelWhitelist'
|
||
|
||
interface Props {
|
||
show: boolean
|
||
accountIds: number[]
|
||
proxies: Proxy[]
|
||
groups: AdminGroup[]
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<{
|
||
close: []
|
||
updated: []
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
const appStore = useAppStore()
|
||
|
||
// Model mapping type
|
||
interface ModelMapping {
|
||
from: string
|
||
to: string
|
||
}
|
||
|
||
// State - field enable flags
|
||
const enableBaseUrl = ref(false)
|
||
const enableModelRestriction = ref(false)
|
||
const enableCustomErrorCodes = ref(false)
|
||
const enableInterceptWarmup = ref(false)
|
||
const enableProxy = ref(false)
|
||
const enableConcurrency = ref(false)
|
||
const enablePriority = ref(false)
|
||
const enableRateMultiplier = ref(false)
|
||
const enableStatus = ref(false)
|
||
const enableGroups = ref(false)
|
||
|
||
// State - field values
|
||
const submitting = ref(false)
|
||
const baseUrl = ref('')
|
||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||
const allowedModels = ref<string[]>([])
|
||
const modelMappings = ref<ModelMapping[]>([])
|
||
const selectedErrorCodes = ref<number[]>([])
|
||
const customErrorCodeInput = ref<number | null>(null)
|
||
const interceptWarmupRequests = ref(false)
|
||
const proxyId = ref<number | null>(null)
|
||
const concurrency = ref(1)
|
||
const priority = ref(1)
|
||
const rateMultiplier = ref(1)
|
||
const status = ref<'active' | 'inactive'>('active')
|
||
const groupIds = ref<number[]>([])
|
||
|
||
// All models list (combined Anthropic + OpenAI + Gemini)
|
||
const allModels = [
|
||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
|
||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
|
||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
|
||
]
|
||
|
||
// Preset mappings (combined Anthropic + OpenAI + Gemini)
|
||
const presetMappings = [
|
||
{
|
||
label: 'Sonnet 4',
|
||
from: 'claude-sonnet-4-20250514',
|
||
to: 'claude-sonnet-4-20250514',
|
||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||
},
|
||
{
|
||
label: 'Sonnet 4.5',
|
||
from: 'claude-sonnet-4-5-20250929',
|
||
to: 'claude-sonnet-4-5-20250929',
|
||
color:
|
||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||
},
|
||
{
|
||
label: 'Opus 4.5',
|
||
from: 'claude-opus-4-5-20251101',
|
||
to: 'claude-opus-4-5-20251101',
|
||
color:
|
||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||
},
|
||
{
|
||
label: 'Opus 4.6',
|
||
from: 'claude-opus-4-6',
|
||
to: 'claude-opus-4-6',
|
||
color:
|
||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||
},
|
||
{
|
||
label: 'Sonnet 4.6',
|
||
from: 'claude-sonnet-4-6',
|
||
to: 'claude-sonnet-4-6',
|
||
color:
|
||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||
},
|
||
{
|
||
label: 'Opus->Sonnet',
|
||
from: 'claude-opus-4-5-20251101',
|
||
to: 'claude-sonnet-4-5-20250929',
|
||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||
},
|
||
{
|
||
label: 'GPT-5.2',
|
||
from: 'gpt-5.2-2025-12-11',
|
||
to: 'gpt-5.2-2025-12-11',
|
||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||
},
|
||
{
|
||
label: 'GPT-5.2 Codex',
|
||
from: 'gpt-5.2-codex',
|
||
to: 'gpt-5.2-codex',
|
||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||
},
|
||
{
|
||
label: 'Max->Codex',
|
||
from: 'gpt-5.1-codex-max',
|
||
to: 'gpt-5.1-codex',
|
||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
|
||
},
|
||
{
|
||
label: 'Gemini Flash 2.0',
|
||
from: 'gemini-2.0-flash',
|
||
to: 'gemini-2.0-flash',
|
||
color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||
},
|
||
{
|
||
label: 'Gemini 2.5 Flash',
|
||
from: 'gemini-2.5-flash',
|
||
to: 'gemini-2.5-flash',
|
||
color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400'
|
||
},
|
||
{
|
||
label: 'Gemini 2.5 Pro',
|
||
from: 'gemini-2.5-pro',
|
||
to: 'gemini-2.5-pro',
|
||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||
}
|
||
]
|
||
|
||
// Common HTTP error codes
|
||
const commonErrorCodes = [
|
||
{ value: 401, label: 'Unauthorized' },
|
||
{ value: 403, label: 'Forbidden' },
|
||
{ value: 429, label: 'Rate Limit' },
|
||
{ value: 500, label: 'Server Error' },
|
||
{ value: 502, label: 'Bad Gateway' },
|
||
{ value: 503, label: 'Unavailable' },
|
||
{ value: 529, label: 'Overloaded' }
|
||
]
|
||
|
||
const statusOptions = computed(() => [
|
||
{ value: 'active', label: t('common.active') },
|
||
{ value: 'inactive', label: t('common.inactive') }
|
||
])
|
||
|
||
// Model mapping helpers
|
||
const addModelMapping = () => {
|
||
modelMappings.value.push({ from: '', to: '' })
|
||
}
|
||
|
||
const removeModelMapping = (index: number) => {
|
||
modelMappings.value.splice(index, 1)
|
||
}
|
||
|
||
const addPresetMapping = (from: string, to: string) => {
|
||
const exists = modelMappings.value.some((m) => m.from === from)
|
||
if (exists) {
|
||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||
return
|
||
}
|
||
modelMappings.value.push({ from, to })
|
||
}
|
||
|
||
// Error code helpers
|
||
const toggleErrorCode = (code: number) => {
|
||
const index = selectedErrorCodes.value.indexOf(code)
|
||
if (index === -1) {
|
||
// Adding code - check for 429/529 warning
|
||
if (code === 429) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||
return
|
||
}
|
||
} else if (code === 529) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||
return
|
||
}
|
||
}
|
||
selectedErrorCodes.value.push(code)
|
||
} else {
|
||
selectedErrorCodes.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
const addCustomErrorCode = () => {
|
||
const code = customErrorCodeInput.value
|
||
if (code === null || code < 100 || code > 599) {
|
||
appStore.showError(t('admin.accounts.invalidErrorCode'))
|
||
return
|
||
}
|
||
if (selectedErrorCodes.value.includes(code)) {
|
||
appStore.showInfo(t('admin.accounts.errorCodeExists'))
|
||
return
|
||
}
|
||
// Check for 429/529 warning
|
||
if (code === 429) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) {
|
||
return
|
||
}
|
||
} else if (code === 529) {
|
||
if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) {
|
||
return
|
||
}
|
||
}
|
||
selectedErrorCodes.value.push(code)
|
||
customErrorCodeInput.value = null
|
||
}
|
||
|
||
const removeErrorCode = (code: number) => {
|
||
const index = selectedErrorCodes.value.indexOf(code)
|
||
if (index !== -1) {
|
||
selectedErrorCodes.value.splice(index, 1)
|
||
}
|
||
}
|
||
|
||
const buildModelMappingObject = (): Record<string, string> | null => {
|
||
return buildModelMappingPayload(
|
||
modelRestrictionMode.value,
|
||
allowedModels.value,
|
||
modelMappings.value
|
||
)
|
||
}
|
||
|
||
const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||
const updates: Record<string, unknown> = {}
|
||
const credentials: Record<string, unknown> = {}
|
||
let credentialsChanged = false
|
||
|
||
if (enableProxy.value) {
|
||
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
||
updates.proxy_id = proxyId.value === null ? 0 : proxyId.value
|
||
}
|
||
|
||
if (enableConcurrency.value) {
|
||
updates.concurrency = concurrency.value
|
||
}
|
||
|
||
if (enablePriority.value) {
|
||
updates.priority = priority.value
|
||
}
|
||
|
||
if (enableRateMultiplier.value) {
|
||
updates.rate_multiplier = rateMultiplier.value
|
||
}
|
||
|
||
if (enableStatus.value) {
|
||
updates.status = status.value
|
||
}
|
||
|
||
if (enableGroups.value) {
|
||
updates.group_ids = groupIds.value
|
||
}
|
||
|
||
if (enableBaseUrl.value) {
|
||
const baseUrlValue = baseUrl.value.trim()
|
||
if (baseUrlValue) {
|
||
credentials.base_url = baseUrlValue
|
||
credentialsChanged = true
|
||
}
|
||
}
|
||
|
||
if (enableModelRestriction.value) {
|
||
const modelMapping = buildModelMappingObject()
|
||
|
||
// 统一使用 model_mapping 字段
|
||
if (modelRestrictionMode.value === 'whitelist') {
|
||
if (allowedModels.value.length > 0) {
|
||
// 白名单模式:将模型转换为 model_mapping 格式(key=value)
|
||
const mapping: Record<string, string> = {}
|
||
for (const m of allowedModels.value) {
|
||
mapping[m] = m
|
||
}
|
||
credentials.model_mapping = mapping
|
||
credentialsChanged = true
|
||
}
|
||
} else {
|
||
if (modelMapping) {
|
||
credentials.model_mapping = modelMapping
|
||
credentialsChanged = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if (enableCustomErrorCodes.value) {
|
||
credentials.custom_error_codes_enabled = true
|
||
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||
credentialsChanged = true
|
||
}
|
||
|
||
if (enableInterceptWarmup.value) {
|
||
credentials.intercept_warmup_requests = interceptWarmupRequests.value
|
||
credentialsChanged = true
|
||
}
|
||
|
||
if (credentialsChanged) {
|
||
updates.credentials = credentials
|
||
}
|
||
|
||
return Object.keys(updates).length > 0 ? updates : null
|
||
}
|
||
|
||
const handleClose = () => {
|
||
emit('close')
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
if (props.accountIds.length === 0) {
|
||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||
return
|
||
}
|
||
|
||
const hasAnyFieldEnabled =
|
||
enableBaseUrl.value ||
|
||
enableModelRestriction.value ||
|
||
enableCustomErrorCodes.value ||
|
||
enableInterceptWarmup.value ||
|
||
enableProxy.value ||
|
||
enableConcurrency.value ||
|
||
enablePriority.value ||
|
||
enableRateMultiplier.value ||
|
||
enableStatus.value ||
|
||
enableGroups.value
|
||
|
||
if (!hasAnyFieldEnabled) {
|
||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||
return
|
||
}
|
||
|
||
const updates = buildUpdatePayload()
|
||
if (!updates) {
|
||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
|
||
try {
|
||
const res = await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
|
||
const success = res.success || 0
|
||
const failed = res.failed || 0
|
||
|
||
if (success > 0 && failed === 0) {
|
||
appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success }))
|
||
} else if (success > 0) {
|
||
appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed }))
|
||
} else {
|
||
appStore.showError(t('admin.accounts.bulkEdit.failed'))
|
||
}
|
||
|
||
if (success > 0) {
|
||
emit('updated')
|
||
handleClose()
|
||
}
|
||
} catch (error: any) {
|
||
appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed'))
|
||
console.error('Error bulk updating accounts:', error)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// Reset form when modal closes
|
||
watch(
|
||
() => props.show,
|
||
(newShow) => {
|
||
if (!newShow) {
|
||
// Reset all enable flags
|
||
enableBaseUrl.value = false
|
||
enableModelRestriction.value = false
|
||
enableCustomErrorCodes.value = false
|
||
enableInterceptWarmup.value = false
|
||
enableProxy.value = false
|
||
enableConcurrency.value = false
|
||
enablePriority.value = false
|
||
enableRateMultiplier.value = false
|
||
enableStatus.value = false
|
||
enableGroups.value = false
|
||
|
||
// Reset all values
|
||
baseUrl.value = ''
|
||
modelRestrictionMode.value = 'whitelist'
|
||
allowedModels.value = []
|
||
modelMappings.value = []
|
||
selectedErrorCodes.value = []
|
||
customErrorCodeInput.value = null
|
||
interceptWarmupRequests.value = false
|
||
proxyId.value = null
|
||
concurrency.value = 1
|
||
priority.value = 1
|
||
rateMultiplier.value = 1
|
||
status.value = 'active'
|
||
groupIds.value = []
|
||
}
|
||
}
|
||
)
|
||
</script>
|