merge upstream/main
This commit is contained in:
134
frontend/src/api/admin/errorPassthrough.ts
Normal file
134
frontend/src/api/admin/errorPassthrough.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Admin Error Passthrough Rules API endpoints
|
||||
* Handles error passthrough rule management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* Error passthrough rule interface
|
||||
*/
|
||||
export interface ErrorPassthroughRule {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
error_codes: number[]
|
||||
keywords: string[]
|
||||
match_mode: 'any' | 'all'
|
||||
platforms: string[]
|
||||
passthrough_code: boolean
|
||||
response_code: number | null
|
||||
passthrough_body: boolean
|
||||
custom_message: string | null
|
||||
description: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule request
|
||||
*/
|
||||
export interface CreateRuleRequest {
|
||||
name: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
error_codes?: number[]
|
||||
keywords?: string[]
|
||||
match_mode?: 'any' | 'all'
|
||||
platforms?: string[]
|
||||
passthrough_code?: boolean
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rule request
|
||||
*/
|
||||
export interface UpdateRuleRequest {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
error_codes?: number[]
|
||||
keywords?: string[]
|
||||
match_mode?: 'any' | 'all'
|
||||
platforms?: string[]
|
||||
passthrough_code?: boolean
|
||||
response_code?: number | null
|
||||
passthrough_body?: boolean
|
||||
custom_message?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* List all error passthrough rules
|
||||
* @returns List of all rules sorted by priority
|
||||
*/
|
||||
export async function list(): Promise<ErrorPassthroughRule[]> {
|
||||
const { data } = await apiClient.get<ErrorPassthroughRule[]>('/admin/error-passthrough-rules')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule by ID
|
||||
* @param id - Rule ID
|
||||
* @returns Rule details
|
||||
*/
|
||||
export async function getById(id: number): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.get<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new rule
|
||||
* @param ruleData - Rule data
|
||||
* @returns Created rule
|
||||
*/
|
||||
export async function create(ruleData: CreateRuleRequest): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.post<ErrorPassthroughRule>('/admin/error-passthrough-rules', ruleData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rule
|
||||
* @param id - Rule ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated rule
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateRuleRequest): Promise<ErrorPassthroughRule> {
|
||||
const { data } = await apiClient.put<ErrorPassthroughRule>(`/admin/error-passthrough-rules/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rule
|
||||
* @param id - Rule ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteRule(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/error-passthrough-rules/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle rule enabled status
|
||||
* @param id - Rule ID
|
||||
* @param enabled - New enabled status
|
||||
* @returns Updated rule
|
||||
*/
|
||||
export async function toggleEnabled(id: number, enabled: boolean): Promise<ErrorPassthroughRule> {
|
||||
return update(id, { enabled })
|
||||
}
|
||||
|
||||
export const errorPassthroughAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteRule,
|
||||
toggleEnabled
|
||||
}
|
||||
|
||||
export default errorPassthroughAPI
|
||||
@@ -19,6 +19,7 @@ import geminiAPI from './gemini'
|
||||
import antigravityAPI from './antigravity'
|
||||
import userAttributesAPI from './userAttributes'
|
||||
import opsAPI from './ops'
|
||||
import errorPassthroughAPI from './errorPassthrough'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -39,7 +40,8 @@ export const adminAPI = {
|
||||
gemini: geminiAPI,
|
||||
antigravity: antigravityAPI,
|
||||
userAttributes: userAttributesAPI,
|
||||
ops: opsAPI
|
||||
ops: opsAPI,
|
||||
errorPassthrough: errorPassthroughAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -58,10 +60,12 @@ export {
|
||||
geminiAPI,
|
||||
antigravityAPI,
|
||||
userAttributesAPI,
|
||||
opsAPI
|
||||
opsAPI,
|
||||
errorPassthroughAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
// Re-export types used by components
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||
|
||||
@@ -18,8 +18,18 @@ export async function getAvailable(): Promise<Group[]> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's custom group rate multipliers
|
||||
* @returns Map of group_id to custom rate_multiplier
|
||||
*/
|
||||
export async function getUserGroupRates(): Promise<Record<number, number>> {
|
||||
const { data } = await apiClient.get<Record<number, number> | null>('/groups/rates')
|
||||
return data || {}
|
||||
}
|
||||
|
||||
export const userGroupsAPI = {
|
||||
getAvailable
|
||||
getAvailable,
|
||||
getUserGroupRates
|
||||
}
|
||||
|
||||
export default userGroupsAPI
|
||||
|
||||
@@ -707,6 +707,7 @@ const groupIds = ref<number[]>([])
|
||||
|
||||
// All models list (combined Anthropic + OpenAI)
|
||||
const allModels = [
|
||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 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' },
|
||||
@@ -746,6 +747,13 @@ const presetMappings = [
|
||||
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: 'Opus->Sonnet',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
|
||||
623
frontend/src/components/admin/ErrorPassthroughRulesModal.vue
Normal file
623
frontend/src/components/admin/ErrorPassthroughRulesModal.vue
Normal file
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.errorPassthrough.title')"
|
||||
width="extra-wide"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.description') }}
|
||||
</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary btn-sm">
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.errorPassthrough.createRule') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules Table -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="rules.length === 0" class="py-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<Icon name="shield" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.errorPassthrough.noRules') }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.createFirstRule') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-dark-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.priority') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.name') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.conditions') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.platforms') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.behavior') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.status') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.columns.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-700 dark:bg-dark-600 dark:text-gray-300">
|
||||
{{ rule.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white text-sm">{{ rule.name }}</div>
|
||||
<div v-if="rule.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{{ rule.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex flex-wrap gap-1 max-w-48">
|
||||
<span
|
||||
v-for="code in rule.error_codes.slice(0, 3)"
|
||||
:key="code"
|
||||
class="badge badge-danger text-xs"
|
||||
>
|
||||
{{ code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="rule.error_codes.length > 3"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
+{{ rule.error_codes.length - 3 }}
|
||||
</span>
|
||||
<span
|
||||
v-for="keyword in rule.keywords.slice(0, 1)"
|
||||
:key="keyword"
|
||||
class="badge badge-gray text-xs"
|
||||
>
|
||||
"{{ keyword.length > 10 ? keyword.substring(0, 10) + '...' : keyword }}"
|
||||
</span>
|
||||
<span
|
||||
v-if="rule.keywords.length > 1"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
+{{ rule.keywords.length - 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.matchMode.' + rule.match_mode) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="rule.platforms.length === 0" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.allPlatforms') }}
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="platform in rule.platforms.slice(0, 2)"
|
||||
:key="platform"
|
||||
class="badge badge-primary text-xs"
|
||||
>
|
||||
{{ platform }}
|
||||
</span>
|
||||
<span v-if="rule.platforms.length > 2" class="text-xs text-gray-500">
|
||||
+{{ rule.platforms.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
:name="rule.passthrough_code ? 'checkCircle' : 'xCircle'"
|
||||
size="xs"
|
||||
:class="rule.passthrough_code ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.code') }}:
|
||||
{{ rule.passthrough_code ? t('admin.errorPassthrough.passthrough') : (rule.response_code || '-') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
:name="rule.passthrough_body ? 'checkCircle' : 'xCircle'"
|
||||
size="xs"
|
||||
:class="rule.passthrough_body ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.errorPassthrough.body') }}:
|
||||
{{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<button
|
||||
@click="toggleEnabled(rule)"
|
||||
:class="[
|
||||
'relative inline-flex h-4 w-7 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',
|
||||
rule.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
rule.enabled ? 'translate-x-3' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleEdit(rule)"
|
||||
class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(rule)"
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('admin.errorPassthrough.editRule') : t('admin.errorPassthrough.createRule')"
|
||||
width="wide"
|
||||
@close="closeFormModal"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.errorPassthrough.form.name') }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.errorPassthrough.form.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.errorPassthrough.form.priority') }}</label>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.errorPassthrough.form.priorityHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.errorPassthrough.form.description') }}</label>
|
||||
<input
|
||||
v-model="form.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.errorPassthrough.form.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Match Conditions -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.errorPassthrough.form.matchConditions') }}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.errorCodes') }}</label>
|
||||
<input
|
||||
v-model="errorCodesInput"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.errorPassthrough.form.errorCodesPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.errorCodesHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.keywords') }}</label>
|
||||
<textarea
|
||||
v-model="keywordsInput"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="t('admin.errorPassthrough.form.keywordsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.errorPassthrough.form.keywordsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.matchMode') }}</label>
|
||||
<div class="mt-1 space-y-2">
|
||||
<label
|
||||
v-for="option in matchModeOptions"
|
||||
:key="option.value"
|
||||
class="flex items-start gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
v-model="form.match_mode"
|
||||
class="mt-0.5 h-3.5 w-3.5 border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ option.label }}</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ option.description }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.platforms') }}</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="platform in platformOptions"
|
||||
:key="platform.value"
|
||||
class="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="platform.value"
|
||||
v-model="form.platforms"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">{{ platform.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="input-hint text-xs mt-1">{{ t('admin.errorPassthrough.form.platformsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Behavior -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.errorPassthrough.form.responseBehavior') }}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.passthrough_code"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.errorPassthrough.form.passthroughCode') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="!form.passthrough_code" class="mt-2">
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.responseCode') }}</label>
|
||||
<input
|
||||
v-model.number="form.response_code"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
class="input text-sm"
|
||||
placeholder="422"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.passthrough_body"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.errorPassthrough.form.passthroughBody') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="!form.passthrough_body" class="mt-2">
|
||||
<label class="input-label text-xs">{{ t('admin.errorPassthrough.form.customMessage') }}</label>
|
||||
<input
|
||||
v-model="form.custom_message"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.errorPassthrough.form.customMessagePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enabled"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.errorPassthrough.form.enabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeFormModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button @click="handleSubmit" :disabled="submitting" class="btn btn-primary">
|
||||
<Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ showEditModal ? t('common.update') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.errorPassthrough.deleteRule')"
|
||||
:message="t('admin.errorPassthrough.deleteConfirm', { name: deletingRule?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { ErrorPassthroughRule } from '@/api/admin/errorPassthrough'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
void emit // suppress unused warning - emit is used via $emit in template
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const rules = ref<ErrorPassthroughRule[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingRule = ref<ErrorPassthroughRule | null>(null)
|
||||
const deletingRule = ref<ErrorPassthroughRule | null>(null)
|
||||
|
||||
// Form inputs for arrays
|
||||
const errorCodesInput = ref('')
|
||||
const keywordsInput = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
match_mode: 'any' as 'any' | 'all',
|
||||
platforms: [] as string[],
|
||||
passthrough_code: true,
|
||||
response_code: null as number | null,
|
||||
passthrough_body: true,
|
||||
custom_message: null as string | null,
|
||||
description: null as string | null
|
||||
})
|
||||
|
||||
const matchModeOptions = computed(() => [
|
||||
{ value: 'any', label: t('admin.errorPassthrough.matchMode.any'), description: t('admin.errorPassthrough.matchMode.anyHint') },
|
||||
{ value: 'all', label: t('admin.errorPassthrough.matchMode.all'), description: t('admin.errorPassthrough.matchMode.allHint') }
|
||||
])
|
||||
|
||||
const platformOptions = [
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' }
|
||||
]
|
||||
|
||||
// Load rules when dialog opens
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
loadRules()
|
||||
}
|
||||
})
|
||||
|
||||
const loadRules = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
rules.value = await adminAPI.errorPassthrough.list()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.errorPassthrough.failedToLoad'))
|
||||
console.error('Error loading rules:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.enabled = true
|
||||
form.priority = 0
|
||||
form.match_mode = 'any'
|
||||
form.platforms = []
|
||||
form.passthrough_code = true
|
||||
form.response_code = null
|
||||
form.passthrough_body = true
|
||||
form.custom_message = null
|
||||
form.description = null
|
||||
errorCodesInput.value = ''
|
||||
keywordsInput.value = ''
|
||||
}
|
||||
|
||||
const closeFormModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingRule.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleEdit = (rule: ErrorPassthroughRule) => {
|
||||
editingRule.value = rule
|
||||
form.name = rule.name
|
||||
form.enabled = rule.enabled
|
||||
form.priority = rule.priority
|
||||
form.match_mode = rule.match_mode
|
||||
form.platforms = [...rule.platforms]
|
||||
form.passthrough_code = rule.passthrough_code
|
||||
form.response_code = rule.response_code
|
||||
form.passthrough_body = rule.passthrough_body
|
||||
form.custom_message = rule.custom_message
|
||||
form.description = rule.description
|
||||
errorCodesInput.value = rule.error_codes.join(', ')
|
||||
keywordsInput.value = rule.keywords.join('\n')
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (rule: ErrorPassthroughRule) => {
|
||||
deletingRule.value = rule
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const parseErrorCodes = (): number[] => {
|
||||
if (!errorCodesInput.value.trim()) return []
|
||||
return errorCodesInput.value
|
||||
.split(/[,\s]+/)
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !isNaN(n) && n > 0)
|
||||
}
|
||||
|
||||
const parseKeywords = (): string[] => {
|
||||
if (!keywordsInput.value.trim()) return []
|
||||
return keywordsInput.value
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.errorPassthrough.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const errorCodes = parseErrorCodes()
|
||||
const keywords = parseKeywords()
|
||||
|
||||
if (errorCodes.length === 0 && keywords.length === 0) {
|
||||
appStore.showError(t('admin.errorPassthrough.conditionsRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
enabled: form.enabled,
|
||||
priority: form.priority,
|
||||
error_codes: errorCodes,
|
||||
keywords: keywords,
|
||||
match_mode: form.match_mode,
|
||||
platforms: form.platforms,
|
||||
passthrough_code: form.passthrough_code,
|
||||
response_code: form.passthrough_code ? null : form.response_code,
|
||||
passthrough_body: form.passthrough_body,
|
||||
custom_message: form.passthrough_body ? null : form.custom_message,
|
||||
description: form.description?.trim() || null
|
||||
}
|
||||
|
||||
if (showEditModal.value && editingRule.value) {
|
||||
await adminAPI.errorPassthrough.update(editingRule.value.id, data)
|
||||
appStore.showSuccess(t('admin.errorPassthrough.ruleUpdated'))
|
||||
} else {
|
||||
await adminAPI.errorPassthrough.create(data)
|
||||
appStore.showSuccess(t('admin.errorPassthrough.ruleCreated'))
|
||||
}
|
||||
|
||||
closeFormModal()
|
||||
loadRules()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToSave'))
|
||||
console.error('Error saving rule:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async (rule: ErrorPassthroughRule) => {
|
||||
try {
|
||||
await adminAPI.errorPassthrough.toggleEnabled(rule.id, !rule.enabled)
|
||||
rule.enabled = !rule.enabled
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToToggle'))
|
||||
console.error('Error toggling rule:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingRule.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.errorPassthrough.delete(deletingRule.value.id)
|
||||
appStore.showSuccess(t('admin.errorPassthrough.ruleDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingRule.value = null
|
||||
loadRules()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.errorPassthrough.failedToDelete'))
|
||||
console.error('Error deleting rule:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,59 +1,328 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.users.setAllowedGroups')" width="normal" @close="$emit('close')">
|
||||
<div v-if="user" class="space-y-4">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100">
|
||||
<span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span>
|
||||
<BaseDialog :show="show" :title="t('admin.users.groupConfig')" width="wide" @close="$emit('close')">
|
||||
<div v-if="user" class="space-y-6">
|
||||
<!-- 用户信息头部 -->
|
||||
<div class="flex items-center gap-4 rounded-2xl bg-gradient-to-r from-primary-50 to-primary-100 p-5 dark:from-primary-900/30 dark:to-primary-800/20">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-sm dark:bg-dark-700">
|
||||
<span class="text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ user.email.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ t('admin.users.groupConfigHint', { email: user.email }) }}</p>
|
||||
</div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
</div>
|
||||
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
|
||||
<div v-else>
|
||||
<p class="mb-3 text-sm text-gray-600">{{ t('admin.users.allowedGroupsHint') }}</p>
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<label v-for="group in groups" :key="group.id" class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
|
||||
<input type="checkbox" :value="group.id" v-model="selectedIds" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ group.name }}</p><p v-if="group.description" class="truncate text-sm text-gray-500">{{ group.description }}</p></div>
|
||||
<div class="flex items-center gap-2"><span class="badge badge-gray text-xs">{{ group.platform }}</span><span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span></div>
|
||||
</label>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<svg class="h-10 w-10 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- 专属分组区域 -->
|
||||
<div v-if="exclusiveGroups.length > 0">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500"></div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.exclusiveGroups') }}</h4>
|
||||
<span class="text-xs text-gray-400">({{ exclusiveGroupConfigs.filter(c => c.isSelected).length }}/{{ exclusiveGroupConfigs.length }})</span>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="config in exclusiveGroupConfigs"
|
||||
:key="config.groupId"
|
||||
class="group relative overflow-hidden rounded-xl border-2 p-4 transition-all duration-200"
|
||||
:class="config.isSelected
|
||||
? 'border-primary-400 bg-primary-50/50 shadow-sm dark:border-primary-500 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:hover:border-dark-500'"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 复选框 -->
|
||||
<div class="flex-shrink-0">
|
||||
<label class="relative flex h-6 w-6 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="config.isSelected"
|
||||
@change="toggleExclusiveGroup(config.groupId)"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<div class="h-5 w-5 rounded-md border-2 border-gray-300 transition-all peer-checked:border-primary-500 peer-checked:bg-primary-500 dark:border-dark-500 peer-checked:dark:border-primary-500">
|
||||
<svg v-if="config.isSelected" class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 分组信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
{{ t('admin.groups.exclusive') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<PlatformIcon :platform="config.platform" size="xs" />
|
||||
<span>{{ config.platform }}</span>
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-dark-500">•</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专属倍率输入 -->
|
||||
<div class="flex flex-shrink-0 items-center gap-3">
|
||||
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
:value="config.customRate ?? ''"
|
||||
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
|
||||
:placeholder="String(config.defaultRate)"
|
||||
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-green-300 bg-green-50': selectedIds.length === 0}">
|
||||
<input type="radio" :checked="selectedIds.length === 0" @change="selectedIds = []" class="h-4 w-4 border-gray-300 text-green-600" />
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ t('admin.users.allowAllGroups') }}</p><p class="text-sm text-gray-500">{{ t('admin.users.allowAllGroupsHint') }}</p></div>
|
||||
</label>
|
||||
|
||||
<!-- 公开分组区域 -->
|
||||
<div v-if="publicGroups.length > 0">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4>
|
||||
<span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="config in publicGroupConfigs"
|
||||
:key="config.groupId"
|
||||
class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 复选框(禁用状态) -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600">
|
||||
<svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<PlatformIcon :platform="config.platform" size="xs" />
|
||||
<span>{{ config.platform }}</span>
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-dark-500">•</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专属倍率输入 -->
|
||||
<div class="flex flex-shrink-0 items-center gap-3">
|
||||
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
:value="config.customRate ?? ''"
|
||||
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
|
||||
:placeholder="String(config.defaultRate)"
|
||||
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无分组提示 -->
|
||||
<div v-if="groups.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('common.noGroupsAvailable') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSave" :disabled="submitting" class="btn btn-primary">{{ submitting ? t('common.saving') : t('common.save') }}</button>
|
||||
<button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSave" :disabled="submitting" class="btn btn-primary px-6">
|
||||
<svg v-if="submitting" class="-ml-1 mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { AdminUser, Group } from '@/types'
|
||||
import type { AdminUser, Group, GroupPlatform } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
interface GroupRateConfig {
|
||||
groupId: number
|
||||
groupName: string
|
||||
platform: GroupPlatform
|
||||
isExclusive: boolean
|
||||
defaultRate: number
|
||||
customRate: number | null
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
|
||||
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
|
||||
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch (error) { console.error('Failed to load groups:', error) } finally { loading.value = false } }
|
||||
const handleSave = async () => {
|
||||
if (!props.user) return; submitting.value = true
|
||||
const groups = ref<Group[]>([])
|
||||
const groupConfigs = ref<GroupRateConfig[]>([])
|
||||
const originalGroupRates = ref<Record<number, number>>({}) // 记录原始专属倍率,用于检测删除
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 分离专属分组和公开分组
|
||||
const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive))
|
||||
const publicGroups = computed(() => groups.value.filter((g) => !g.is_exclusive))
|
||||
|
||||
const exclusiveGroupConfigs = computed(() => groupConfigs.value.filter((c) => c.isExclusive))
|
||||
const publicGroupConfigs = computed(() => groupConfigs.value.filter((c) => !c.isExclusive))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v && props.user) {
|
||||
load()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
|
||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
||||
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
|
||||
const res = await adminAPI.groups.list(1, 1000)
|
||||
// 只显示标准类型且活跃的分组
|
||||
groups.value = res.items.filter((g) => g.subscription_type === 'standard' && g.status === 'active')
|
||||
|
||||
// 初始化配置
|
||||
const userAllowedGroups = props.user?.allowed_groups || []
|
||||
const userGroupRates = props.user?.group_rates || {}
|
||||
|
||||
// 保存原始专属倍率,用于检测删除操作
|
||||
originalGroupRates.value = { ...userGroupRates }
|
||||
|
||||
groupConfigs.value = groups.value.map((g) => ({
|
||||
groupId: g.id,
|
||||
groupName: g.name,
|
||||
platform: g.platform,
|
||||
isExclusive: g.is_exclusive,
|
||||
defaultRate: g.rate_multiplier,
|
||||
customRate: userGroupRates[g.id] ?? null,
|
||||
// 专属分组:检查是否在 allowed_groups 中
|
||||
// 公开分组:始终选中
|
||||
isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExclusiveGroup = (groupId: number) => {
|
||||
const config = groupConfigs.value.find((c) => c.groupId === groupId)
|
||||
if (config && config.isExclusive) {
|
||||
config.isSelected = !config.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
const updateCustomRate = (groupId: number, value: string) => {
|
||||
const config = groupConfigs.value.find((c) => c.groupId === groupId)
|
||||
if (config) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
config.customRate = null
|
||||
} else {
|
||||
const numValue = parseFloat(value)
|
||||
config.customRate = isNaN(numValue) ? null : numValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!props.user) return
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
// 构建 allowed_groups(仅包含专属分组中被勾选的)
|
||||
const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId)
|
||||
|
||||
// 构建 group_rates
|
||||
// - 有新专属倍率: 设置为该值
|
||||
// - 原本有专属倍率但现在被清空: 设置为 null(表示删除)
|
||||
const groupRates: Record<number, number | null> = {}
|
||||
for (const c of groupConfigs.value) {
|
||||
const hadOriginalRate = originalGroupRates.value[c.groupId] !== undefined
|
||||
|
||||
if (c.customRate !== null) {
|
||||
// 有专属倍率
|
||||
groupRates[c.groupId] = c.customRate
|
||||
} else if (hadOriginalRate) {
|
||||
// 原本有专属倍率,现在被清空,需要显式删除
|
||||
groupRates[c.groupId] = null
|
||||
}
|
||||
}
|
||||
|
||||
await adminAPI.users.update(props.user.id, {
|
||||
allowed_groups: allowedGroups,
|
||||
group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined,
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.users.groupConfigUpdated'))
|
||||
emit('success')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Failed to update user group config:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 隐藏数字输入框的箭头按钮 */
|
||||
.hide-spinner::-webkit-outer-spin-button,
|
||||
.hide-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hide-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<!-- Right side label -->
|
||||
<span v-if="showLabel" :class="labelClass">
|
||||
{{ labelText }}
|
||||
<template v-if="hasCustomRate">
|
||||
<!-- 原倍率删除线 + 专属倍率高亮 -->
|
||||
<span class="line-through opacity-50 mr-0.5">{{ rateMultiplier }}x</span>
|
||||
<span class="font-bold">{{ userRateMultiplier }}x</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ labelText }}
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -27,6 +34,7 @@ interface Props {
|
||||
platform?: GroupPlatform
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
userRateMultiplier?: number | null // 用户专属倍率
|
||||
showRate?: boolean
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
}
|
||||
@@ -34,20 +42,31 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
showRate: true,
|
||||
daysRemaining: null
|
||||
daysRemaining: null,
|
||||
userRateMultiplier: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
||||
|
||||
// 是否有专属倍率(且与默认倍率不同)
|
||||
const hasCustomRate = computed(() => {
|
||||
return (
|
||||
props.userRateMultiplier !== null &&
|
||||
props.userRateMultiplier !== undefined &&
|
||||
props.rateMultiplier !== undefined &&
|
||||
props.userRateMultiplier !== props.rateMultiplier
|
||||
)
|
||||
})
|
||||
|
||||
// 是否显示右侧标签
|
||||
const showLabel = computed(() => {
|
||||
if (!props.showRate) return false
|
||||
// 订阅类型:显示天数或"订阅"
|
||||
if (isSubscription.value) return true
|
||||
// 标准类型:显示倍率
|
||||
return props.rateMultiplier !== undefined
|
||||
// 标准类型:显示倍率(包括专属倍率)
|
||||
return props.rateMultiplier !== undefined || hasCustomRate.value
|
||||
})
|
||||
|
||||
// Label text
|
||||
@@ -71,7 +90,7 @@ const labelClass = computed(() => {
|
||||
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
||||
|
||||
if (!isSubscription.value) {
|
||||
// Standard: subtle background
|
||||
// Standard: subtle background (不再为专属倍率使用不同的背景色)
|
||||
return `${base} bg-black/10 dark:bg-white/10`
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:platform="platform"
|
||||
:subscription-type="subscriptionType"
|
||||
:rate-multiplier="rateMultiplier"
|
||||
:user-rate-multiplier="userRateMultiplier"
|
||||
/>
|
||||
<span
|
||||
v-if="description"
|
||||
@@ -39,6 +40,7 @@ interface Props {
|
||||
platform: GroupPlatform
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
userRateMultiplier?: number | null
|
||||
description?: string | null
|
||||
selected?: boolean
|
||||
showCheckmark?: boolean
|
||||
@@ -47,6 +49,7 @@ interface Props {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
selected: false,
|
||||
showCheckmark: true
|
||||
showCheckmark: true,
|
||||
userRateMultiplier: null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const claudeModels = [
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-6',
|
||||
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
|
||||
]
|
||||
|
||||
@@ -210,9 +211,10 @@ const anthropicPresetMappings = [
|
||||
{ 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: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-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: 'Opus->Sonnet', from: 'claude-opus-4-6', 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' }
|
||||
]
|
||||
|
||||
const openaiPresetMappings = [
|
||||
|
||||
@@ -850,6 +850,16 @@ export default {
|
||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||
failedToLoadGroups: 'Failed to load groups',
|
||||
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
||||
// User Group Configuration
|
||||
groupConfig: 'User Group Configuration',
|
||||
groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)',
|
||||
exclusiveGroups: 'Exclusive Groups',
|
||||
publicGroups: 'Public Groups (Default Available)',
|
||||
defaultRate: 'Default Rate',
|
||||
customRate: 'Custom Rate',
|
||||
useDefaultRate: 'Use Default',
|
||||
customRatePlaceholder: 'Leave empty for default',
|
||||
groupConfigUpdated: 'Group configuration updated successfully',
|
||||
deposit: 'Deposit',
|
||||
withdraw: 'Withdraw',
|
||||
depositAmount: 'Deposit Amount',
|
||||
@@ -3225,6 +3235,80 @@ export default {
|
||||
failedToSave: 'Failed to save settings',
|
||||
failedToTestSmtp: 'SMTP connection test failed',
|
||||
failedToSendTestEmail: 'Failed to send test email'
|
||||
},
|
||||
|
||||
// Error Passthrough Rules
|
||||
errorPassthrough: {
|
||||
title: 'Error Passthrough Rules',
|
||||
description: 'Configure how upstream errors are returned to clients',
|
||||
createRule: 'Create Rule',
|
||||
editRule: 'Edit Rule',
|
||||
deleteRule: 'Delete Rule',
|
||||
noRules: 'No rules configured',
|
||||
createFirstRule: 'Create your first error passthrough rule',
|
||||
allPlatforms: 'All Platforms',
|
||||
passthrough: 'Passthrough',
|
||||
custom: 'Custom',
|
||||
code: 'Code',
|
||||
body: 'Body',
|
||||
|
||||
// Columns
|
||||
columns: {
|
||||
priority: 'Priority',
|
||||
name: 'Name',
|
||||
conditions: 'Conditions',
|
||||
platforms: 'Platforms',
|
||||
behavior: 'Behavior',
|
||||
status: 'Status',
|
||||
actions: 'Actions'
|
||||
},
|
||||
|
||||
// Match Mode
|
||||
matchMode: {
|
||||
any: 'Code OR Keyword',
|
||||
all: 'Code AND Keyword',
|
||||
anyHint: 'Status code matches any error code, OR message contains any keyword',
|
||||
allHint: 'Status code matches any error code, AND message contains any keyword'
|
||||
},
|
||||
|
||||
// Form
|
||||
form: {
|
||||
name: 'Rule Name',
|
||||
namePlaceholder: 'e.g., Context Limit Passthrough',
|
||||
priority: 'Priority',
|
||||
priorityHint: 'Lower values have higher priority',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Describe the purpose of this rule...',
|
||||
matchConditions: 'Match Conditions',
|
||||
errorCodes: 'Error Codes',
|
||||
errorCodesPlaceholder: '422, 400, 429',
|
||||
errorCodesHint: 'Separate multiple codes with commas',
|
||||
keywords: 'Keywords',
|
||||
keywordsPlaceholder: 'One keyword per line\ncontext limit\nmodel not supported',
|
||||
keywordsHint: 'One keyword per line, case-insensitive',
|
||||
matchMode: 'Match Mode',
|
||||
platforms: 'Platforms',
|
||||
platformsHint: 'Leave empty to apply to all platforms',
|
||||
responseBehavior: 'Response Behavior',
|
||||
passthroughCode: 'Passthrough upstream status code',
|
||||
responseCode: 'Custom status code',
|
||||
passthroughBody: 'Passthrough upstream error message',
|
||||
customMessage: 'Custom error message',
|
||||
customMessagePlaceholder: 'Error message to return to client...',
|
||||
enabled: 'Enable this rule'
|
||||
},
|
||||
|
||||
// Messages
|
||||
nameRequired: 'Please enter rule name',
|
||||
conditionsRequired: 'Please configure at least one error code or keyword',
|
||||
ruleCreated: 'Rule created successfully',
|
||||
ruleUpdated: 'Rule updated successfully',
|
||||
ruleDeleted: 'Rule deleted successfully',
|
||||
deleteConfirm: 'Are you sure you want to delete rule "{name}"?',
|
||||
failedToLoad: 'Failed to load rules',
|
||||
failedToSave: 'Failed to save rule',
|
||||
failedToDelete: 'Failed to delete rule',
|
||||
failedToToggle: 'Failed to toggle status'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -911,6 +911,16 @@ export default {
|
||||
allowedGroupsUpdated: '允许分组更新成功',
|
||||
failedToLoadGroups: '加载分组列表失败',
|
||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||
// 用户分组配置
|
||||
groupConfig: '用户分组配置',
|
||||
groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)',
|
||||
exclusiveGroups: '专属分组',
|
||||
publicGroups: '公开分组(默认可用)',
|
||||
defaultRate: '默认倍率',
|
||||
customRate: '专属倍率',
|
||||
useDefaultRate: '使用默认',
|
||||
customRatePlaceholder: '留空使用默认',
|
||||
groupConfigUpdated: '分组配置更新成功',
|
||||
deposit: '充值',
|
||||
withdraw: '退款',
|
||||
depositAmount: '充值金额',
|
||||
@@ -3396,6 +3406,80 @@ export default {
|
||||
failedToSave: '保存设置失败',
|
||||
failedToTestSmtp: 'SMTP 连接测试失败',
|
||||
failedToSendTestEmail: '发送测试邮件失败'
|
||||
},
|
||||
|
||||
// Error Passthrough Rules
|
||||
errorPassthrough: {
|
||||
title: '错误透传规则',
|
||||
description: '配置上游错误如何返回给客户端',
|
||||
createRule: '创建规则',
|
||||
editRule: '编辑规则',
|
||||
deleteRule: '删除规则',
|
||||
noRules: '暂无规则',
|
||||
createFirstRule: '创建第一条错误透传规则',
|
||||
allPlatforms: '所有平台',
|
||||
passthrough: '透传',
|
||||
custom: '自定义',
|
||||
code: '状态码',
|
||||
body: '消息体',
|
||||
|
||||
// Columns
|
||||
columns: {
|
||||
priority: '优先级',
|
||||
name: '名称',
|
||||
conditions: '匹配条件',
|
||||
platforms: '平台',
|
||||
behavior: '响应行为',
|
||||
status: '状态',
|
||||
actions: '操作'
|
||||
},
|
||||
|
||||
// Match Mode
|
||||
matchMode: {
|
||||
any: '错误码 或 关键词',
|
||||
all: '错误码 且 关键词',
|
||||
anyHint: '状态码匹配任一错误码,或消息包含任一关键词',
|
||||
allHint: '状态码匹配任一错误码,且消息包含任一关键词'
|
||||
},
|
||||
|
||||
// Form
|
||||
form: {
|
||||
name: '规则名称',
|
||||
namePlaceholder: '例如:上下文超限透传',
|
||||
priority: '优先级',
|
||||
priorityHint: '数值越小优先级越高,优先匹配',
|
||||
description: '规则描述',
|
||||
descriptionPlaceholder: '描述此规则的用途...',
|
||||
matchConditions: '匹配条件',
|
||||
errorCodes: '错误码',
|
||||
errorCodesPlaceholder: '422, 400, 429',
|
||||
errorCodesHint: '多个错误码用逗号分隔',
|
||||
keywords: '关键词',
|
||||
keywordsPlaceholder: '每行一个关键词\ncontext limit\nmodel not supported',
|
||||
keywordsHint: '每行一个关键词,不区分大小写',
|
||||
matchMode: '匹配模式',
|
||||
platforms: '适用平台',
|
||||
platformsHint: '不选择表示适用于所有平台',
|
||||
responseBehavior: '响应行为',
|
||||
passthroughCode: '透传上游状态码',
|
||||
responseCode: '自定义状态码',
|
||||
passthroughBody: '透传上游错误信息',
|
||||
customMessage: '自定义错误信息',
|
||||
customMessagePlaceholder: '返回给客户端的错误信息...',
|
||||
enabled: '启用此规则'
|
||||
},
|
||||
|
||||
// Messages
|
||||
nameRequired: '请输入规则名称',
|
||||
conditionsRequired: '请至少配置一个错误码或关键词',
|
||||
ruleCreated: '规则创建成功',
|
||||
ruleUpdated: '规则更新成功',
|
||||
ruleDeleted: '规则删除成功',
|
||||
deleteConfirm: '确定要删除规则 "{name}" 吗?',
|
||||
failedToLoad: '加载规则失败',
|
||||
failedToSave: '保存规则失败',
|
||||
failedToDelete: '删除规则失败',
|
||||
failedToToggle: '切换状态失败'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface User {
|
||||
export interface AdminUser extends User {
|
||||
// 管理员备注(普通用户接口不返回)
|
||||
notes: string
|
||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
||||
group_rates?: Record<number, number>
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -1016,6 +1018,9 @@ export interface UpdateUserRequest {
|
||||
concurrency?: number
|
||||
status?: 'active' | 'disabled'
|
||||
allowed_groups?: number[] | null
|
||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier | null)
|
||||
// null 表示删除该分组的专属倍率
|
||||
group_rates?: Record<number, number | null>
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #before>
|
||||
<button
|
||||
@click="showErrorPassthrough = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.errorPassthrough.title')"
|
||||
>
|
||||
<Icon name="shield" size="md" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #after>
|
||||
<!-- Auto Refresh Dropdown -->
|
||||
<div class="relative" ref="autoRefreshDropdownRef">
|
||||
@@ -245,6 +255,7 @@
|
||||
<span>{{ t('admin.accounts.dataExportIncludeProxies') }}</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -277,6 +288,7 @@ import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
||||
|
||||
@@ -299,6 +311,7 @@ const showDeleteDialog = ref(false)
|
||||
const showReAuth = ref(false)
|
||||
const showTest = ref(false)
|
||||
const showStats = ref(false)
|
||||
const showErrorPassthrough = ref(false)
|
||||
const edAcc = ref<Account | null>(null)
|
||||
const tempUnschedAcc = ref<Account | null>(null)
|
||||
const deletingAcc = ref<Account | null>(null)
|
||||
@@ -441,7 +454,8 @@ const isAnyModalOpen = computed(() => {
|
||||
showDeleteDialog.value ||
|
||||
showReAuth.value ||
|
||||
showTest.value ||
|
||||
showStats.value
|
||||
showStats.value ||
|
||||
showErrorPassthrough.value
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
:platform="row.group.platform"
|
||||
:subscription-type="row.group.subscription_type"
|
||||
:rate-multiplier="row.group.rate_multiplier"
|
||||
:user-rate-multiplier="userGroupRates[row.group.id]"
|
||||
/>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
|
||||
t('keys.noGroup')
|
||||
@@ -272,6 +273,7 @@
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:user-rate-multiplier="(option as unknown as GroupOption).userRate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||
</template>
|
||||
@@ -281,6 +283,7 @@
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:user-rate-multiplier="(option as unknown as GroupOption).userRate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
@@ -667,6 +670,7 @@
|
||||
:platform="option.platform"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
:user-rate-multiplier="option.userRate"
|
||||
:description="option.description"
|
||||
:selected="
|
||||
selectedKeyForGroup?.group_id === option.value ||
|
||||
@@ -718,6 +722,7 @@ interface GroupOption {
|
||||
label: string
|
||||
description: string | null
|
||||
rate: number
|
||||
userRate: number | null
|
||||
subscriptionType: SubscriptionType
|
||||
platform: GroupPlatform
|
||||
}
|
||||
@@ -742,6 +747,7 @@ const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
|
||||
const userGroupRates = ref<Record<number, number>>({})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -825,6 +831,7 @@ const groupOptions = computed(() =>
|
||||
label: group.name,
|
||||
description: group.description,
|
||||
rate: group.rate_multiplier,
|
||||
userRate: userGroupRates.value[group.id] ?? null,
|
||||
subscriptionType: group.subscription_type,
|
||||
platform: group.platform
|
||||
}))
|
||||
@@ -899,6 +906,14 @@ const loadGroups = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserGroupRates = async () => {
|
||||
try {
|
||||
userGroupRates.value = await userGroupsAPI.getUserGroupRates()
|
||||
} catch (error) {
|
||||
console.error('Failed to load user group rates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPublicSettings = async () => {
|
||||
try {
|
||||
publicSettings.value = await authAPI.getPublicSettings()
|
||||
@@ -1268,6 +1283,7 @@ const closeCcsClientSelect = () => {
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadGroups()
|
||||
loadUserGroupRates()
|
||||
loadPublicSettings()
|
||||
document.addEventListener('click', closeGroupSelector)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user