mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 20:20:19 +08:00
refactor(frontend): modularize i18n architecture
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
class="platform-select__dropdown"
|
||||
>
|
||||
<li
|
||||
v-for="option in resolvedOptions"
|
||||
v-for="option in displayOptions"
|
||||
:key="option.value"
|
||||
class="platform-select__option"
|
||||
:class="{ 'platform-select__option--active': option.value === modelValue }"
|
||||
@@ -59,65 +59,11 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Apple, Box, Monitor, Terminal } from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface PlatformOption {
|
||||
value: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: Component
|
||||
command: string
|
||||
}
|
||||
|
||||
// Default options for backward compatibility
|
||||
export const defaultPlatformOptions: PlatformOption[] = [
|
||||
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: '' },
|
||||
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: '' }
|
||||
]
|
||||
|
||||
// Preset configuration for each tool
|
||||
export const platformPresets = {
|
||||
default: {
|
||||
options: defaultPlatformOptions,
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
claude: {
|
||||
options: [
|
||||
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
|
||||
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask claude-code' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
codex: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @openai/codex' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask codex' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
},
|
||||
gemini: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install gemini-cli' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get command by platform value
|
||||
export function getCommand(preset: keyof typeof platformPresets, value: string): string {
|
||||
const config = platformPresets[preset]
|
||||
return config.options.find((opt: PlatformOption) => opt.value === value)?.command ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { defaultPlatformOptions, type PlatformOption } from '@/config/platform-presets'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
@@ -129,13 +75,21 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const sizeClass = computed(() => props.size ?? 'md')
|
||||
|
||||
const resolvedOptions = computed(() => props.options ?? defaultPlatformOptions)
|
||||
const displayOptions = computed(() => resolvedOptions.value.map(option => ({
|
||||
...option,
|
||||
label: t(option.labelKey),
|
||||
hint: t(option.hintKey),
|
||||
})))
|
||||
|
||||
const currentOption = computed(() => resolvedOptions.value.find((option: PlatformOption) => option.value === props.modelValue) ?? resolvedOptions.value[0])
|
||||
const currentOption = computed(() =>
|
||||
displayOptions.value.find((option) => option.value === props.modelValue) ?? displayOptions.value[0]
|
||||
)
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ title }}
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
class="h-10 px-5"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
{{ displayCancelText }}
|
||||
</Button>
|
||||
|
||||
<!-- 确认按钮 -->
|
||||
@@ -58,7 +58,7 @@
|
||||
v-if="loading"
|
||||
class="animate-spin h-4 w-4 mr-2"
|
||||
/>
|
||||
{{ confirmText }}
|
||||
{{ displayConfirmText }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -69,6 +69,7 @@ import { computed } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { AlertTriangle, AlertCircle, Info, Trash2, HelpCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export type AlertType = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
|
||||
|
||||
@@ -96,10 +97,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const displayTitle = computed(() => legacyT(props.title))
|
||||
const displayConfirmText = computed(() => legacyT(props.confirmText))
|
||||
const displayCancelText = computed(() => legacyT(props.cancelText))
|
||||
|
||||
// 解析描述文本为多行
|
||||
const descriptionLines = computed(() => {
|
||||
return props.description.split('\n').filter(line => line.trim())
|
||||
return legacyT(props.description).split('\n').filter(line => line.trim())
|
||||
})
|
||||
|
||||
function escapeHtml(raw: string): string {
|
||||
|
||||
@@ -16,18 +16,18 @@
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3
|
||||
v-if="title"
|
||||
v-if="displayTitle"
|
||||
:class="titleClasses"
|
||||
>
|
||||
{{ title }}
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p
|
||||
v-if="description"
|
||||
v-if="displayDescription"
|
||||
:class="descriptionClasses"
|
||||
>
|
||||
{{ description }}
|
||||
{{ displayDescription }}
|
||||
</p>
|
||||
|
||||
<!-- 自定义内容插槽 -->
|
||||
@@ -40,12 +40,12 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div
|
||||
v-if="$slots.actions || actionText"
|
||||
v-if="$slots.actions || displayActionText"
|
||||
class="mt-6 flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="actionText"
|
||||
v-if="displayActionText"
|
||||
:variant="actionVariant"
|
||||
:size="actionSize"
|
||||
@click="handleAction"
|
||||
@@ -55,7 +55,7 @@
|
||||
v-if="actionIcon"
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{{ actionText }}
|
||||
{{ displayActionText }}
|
||||
</Button>
|
||||
</slot>
|
||||
</div>
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
Filter
|
||||
} from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
type EmptyStateType = 'default' | 'search' | 'filter' | 'error' | 'empty' | 'notFound'
|
||||
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive'
|
||||
@@ -128,6 +129,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
// 根据类型获取默认配置
|
||||
const typeConfig = computed(() => {
|
||||
@@ -169,6 +171,9 @@ const typeConfig = computed(() => {
|
||||
|
||||
// 默认图标
|
||||
const defaultIcon = computed(() => typeConfig.value.icon)
|
||||
const displayTitle = computed(() => legacyT(props.title || typeConfig.value.title))
|
||||
const displayDescription = computed(() => legacyT(props.description || typeConfig.value.description))
|
||||
const displayActionText = computed(() => props.actionText ? legacyT(props.actionText) : '')
|
||||
|
||||
// 容器样式
|
||||
const containerClasses = computed(() => {
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
{{ dropTitle }}
|
||||
{{ localizedDropTitle }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
{{ dropHint }}
|
||||
{{ localizedDropHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +40,12 @@
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label v-if="manualLabel">
|
||||
{{ manualLabel }}
|
||||
{{ localizedManualLabel }}
|
||||
</Label>
|
||||
<Textarea
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="manualPlaceholder"
|
||||
:placeholder="localizedManualPlaceholder"
|
||||
:class="textareaClass"
|
||||
spellcheck="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@@ -54,7 +54,7 @@
|
||||
v-if="manualDescription"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ manualDescription }}
|
||||
{{ localizedManualDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = true"
|
||||
>
|
||||
{{ pasteToggleText }}
|
||||
{{ localizedPasteToggleText }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -73,7 +73,7 @@
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="switchToFileMode"
|
||||
>
|
||||
{{ fileToggleText }}
|
||||
{{ localizedFileToggleText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +83,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Label, Textarea } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface ImportInputErrorPayload {
|
||||
message: string
|
||||
@@ -122,10 +123,18 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [payload: ImportInputErrorPayload]
|
||||
}>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const showManualInput = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const localizedDropTitle = computed(() => legacyT(props.dropTitle))
|
||||
const localizedDropHint = computed(() => legacyT(props.dropHint))
|
||||
const localizedManualLabel = computed(() => props.manualLabel ? legacyT(props.manualLabel) : '')
|
||||
const localizedManualPlaceholder = computed(() => props.manualPlaceholder ? legacyT(props.manualPlaceholder) : '')
|
||||
const localizedManualDescription = computed(() => props.manualDescription ? legacyT(props.manualDescription) : '')
|
||||
const localizedPasteToggleText = computed(() => legacyT(props.pasteToggleText))
|
||||
const localizedFileToggleText = computed(() => legacyT(props.fileToggleText))
|
||||
|
||||
const acceptParts = computed(() => {
|
||||
return props.accept
|
||||
@@ -146,7 +155,10 @@ function resetUiState() {
|
||||
}
|
||||
|
||||
function emitError(message: string, title?: string) {
|
||||
emit('error', { message, title })
|
||||
emit('error', {
|
||||
message: legacyT(message),
|
||||
title: title ? legacyT(title) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
@@ -180,9 +192,9 @@ function readFileAsText(file: File): Promise<string> {
|
||||
resolve(content)
|
||||
return
|
||||
}
|
||||
reject(new Error('读取失败'))
|
||||
reject(new Error(legacyT('读取失败')))
|
||||
}
|
||||
reader.onerror = () => reject(new Error('读取失败'))
|
||||
reader.onerror = () => reject(new Error(legacyT('读取失败')))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
|
||||
:aria-label="t('common.language')"
|
||||
:title="t('common.language')"
|
||||
type="button"
|
||||
>
|
||||
<Languages class="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="min-w-36"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="justify-between gap-3"
|
||||
@select="setLocale(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="locale === option.value"
|
||||
class="h-4 w-4 text-primary"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Check, Languages } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n, useLocaleOptions, type Locale } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { locale, setLocale } = useLocaleOptions()
|
||||
|
||||
const options = computed<Array<{ value: Locale; label: string }>>(() => [
|
||||
{ value: 'zh-CN', label: t('common.chinese') },
|
||||
{ value: 'en-US', label: t('common.english') },
|
||||
])
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
<span
|
||||
v-if="invalidItems.length"
|
||||
class="text-destructive"
|
||||
>({{ invalidItems.length }} 个已失效)</span>
|
||||
>{{ invalidSummaryText }}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
|
||||
@@ -47,7 +47,7 @@
|
||||
/>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
:placeholder="localizedSearchPlaceholder"
|
||||
class="h-9 rounded-xl border-border/60 bg-background/80 pl-9 pr-3 text-sm"
|
||||
@keydown.stop
|
||||
/>
|
||||
@@ -64,12 +64,12 @@
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isPartiallySelected"
|
||||
aria-label="全选"
|
||||
:aria-label="legacyT('全选')"
|
||||
class="h-4 w-4 shrink-0 cursor-pointer rounded border-border/60 bg-card/80 text-primary shadow-sm accent-primary focus:ring-2 focus:ring-primary/40 focus:ring-offset-1"
|
||||
@click.stop
|
||||
@change="toggleAll"
|
||||
>
|
||||
<span class="min-w-0 truncate text-sm">全选</span>
|
||||
<span class="min-w-0 truncate text-sm">{{ legacyT('全选') }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{{ selectedOptionCount }}/{{ options.length }}
|
||||
</span>
|
||||
@@ -89,7 +89,7 @@
|
||||
@change="remove(item)"
|
||||
>
|
||||
<span class="min-w-0 truncate text-sm text-destructive">{{ item }}</span>
|
||||
<span class="shrink-0 text-xs text-destructive/70">(已失效)</span>
|
||||
<span class="shrink-0 text-xs text-destructive/70">{{ legacyT('(已失效)') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -111,7 +111,7 @@
|
||||
v-if="filteredOptions.length === 0 && filteredInvalidItems.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ searchQuery.trim() ? noResultsText : emptyText }}
|
||||
{{ searchQuery.trim() ? localizedNoResultsText : localizedEmptyText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +124,7 @@ import { ChevronDown, Search } from 'lucide-vue-next'
|
||||
import { Input } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesSearchQuery } from '@/utils/search'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string
|
||||
@@ -160,6 +161,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -190,6 +192,15 @@ const showSearch = computed(
|
||||
() => props.searchable && totalCount.value >= props.searchThreshold,
|
||||
)
|
||||
|
||||
const localizedPlaceholder = computed(() => legacyT(props.placeholder))
|
||||
const localizedEmptyText = computed(() => legacyT(props.emptyText))
|
||||
const localizedNoResultsText = computed(() => legacyT(props.noResultsText))
|
||||
const localizedSearchPlaceholder = computed(() => legacyT(props.searchPlaceholder))
|
||||
const invalidSummaryText = computed(() => {
|
||||
const count = invalidItems.value.length
|
||||
return locale.value === 'en-US' ? `(${count} invalid)` : `(${count} 个已失效)`
|
||||
})
|
||||
|
||||
const filteredInvalidItems = computed(() => {
|
||||
if (!showSearch.value || !searchQuery.value.trim()) {
|
||||
return invalidItems.value
|
||||
@@ -210,13 +221,15 @@ const filteredOptions = computed(() => {
|
||||
})
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.modelValue.length === 0) return props.placeholder
|
||||
if (props.modelValue.length === 0) return localizedPlaceholder.value
|
||||
if (props.modelValue.length <= 2) {
|
||||
return props.modelValue
|
||||
.map((v) => props.options.find((o) => o.value === v)?.label ?? v)
|
||||
.join(', ')
|
||||
}
|
||||
return `已选择 ${props.modelValue.length} 项`
|
||||
return locale.value === 'en-US'
|
||||
? `${props.modelValue.length} selected`
|
||||
: `已选择 ${props.modelValue.length} 项`
|
||||
})
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<DialogTitle>
|
||||
{{ title }}
|
||||
{{ displayTitle }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ description }}
|
||||
{{ displayDescription }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
支付方式
|
||||
{{ legacyT('支付方式') }}
|
||||
</div>
|
||||
<div class="text-sm text-foreground">
|
||||
{{ stripeDisplayName }}
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
应付金额
|
||||
{{ legacyT('应付金额') }}
|
||||
</div>
|
||||
<div class="text-sm text-foreground">
|
||||
{{ stripeAmountLabel }}
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
支付通道
|
||||
{{ legacyT('支付通道') }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
@@ -87,7 +87,7 @@
|
||||
class="absolute inset-3 flex items-center justify-center gap-2 rounded-lg bg-background/80 text-sm text-muted-foreground backdrop-blur-sm"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Stripe 支付组件...
|
||||
{{ legacyT('正在加载 Stripe 支付组件...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
:disabled="initializing || submitting"
|
||||
@click="closeDialog"
|
||||
>
|
||||
关闭
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canSubmit"
|
||||
@@ -115,7 +115,7 @@
|
||||
v-if="submitting"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
{{ submitting ? '支付中...' : confirmText }}
|
||||
{{ submitting ? legacyT('支付中...') : displayConfirmText }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
type PaymentInstructionMap,
|
||||
} from '@/utils/paymentInstructions'
|
||||
import { paymentMethodLabel } from '@/utils/walletDisplay'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -152,11 +153,15 @@ const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
success: [payload: { intentId: string; status?: string | null }]
|
||||
}>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
set: value => emit('update:open', value),
|
||||
})
|
||||
const displayTitle = computed(() => legacyT(props.title))
|
||||
const displayDescription = computed(() => legacyT(props.description))
|
||||
const displayConfirmText = computed(() => legacyT(props.confirmText))
|
||||
|
||||
const paymentElementRoot = ref<HTMLDivElement | null>(null)
|
||||
const stripeInstance = ref<Stripe | null>(null)
|
||||
@@ -218,7 +223,7 @@ async function initializeStripe() {
|
||||
const instructions = stripeInstructions.value
|
||||
if (!props.open) return
|
||||
if (!instructions) {
|
||||
errorMessage.value = '缺少 Stripe 支付参数'
|
||||
errorMessage.value = legacyT('缺少 Stripe 支付参数')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,12 +248,12 @@ async function initializeStripe() {
|
||||
try {
|
||||
await nextTick()
|
||||
if (!paymentElementRoot.value) {
|
||||
throw new Error('支付容器未准备好')
|
||||
throw new Error(legacyT('支付容器未准备好'))
|
||||
}
|
||||
|
||||
const stripe = await loadStripeCached(instructions.publishableKey)
|
||||
if (!stripe) {
|
||||
throw new Error('Stripe 初始化失败')
|
||||
throw new Error(legacyT('Stripe 初始化失败'))
|
||||
}
|
||||
if (sequence !== mountSequence) return
|
||||
|
||||
@@ -277,7 +282,7 @@ async function initializeStripe() {
|
||||
async function submitPayment() {
|
||||
const instructions = stripeInstructions.value
|
||||
if (!instructions || !stripeInstance.value || !elementsInstance.value) {
|
||||
errorMessage.value = 'Stripe 支付组件未就绪'
|
||||
errorMessage.value = legacyT('Stripe 支付组件未就绪')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +292,7 @@ async function submitPayment() {
|
||||
try {
|
||||
const submitResult = await elementsInstance.value.submit()
|
||||
if (submitResult.error) {
|
||||
errorMessage.value = submitResult.error.message || '请检查支付信息'
|
||||
errorMessage.value = submitResult.error.message || legacyT('请检查支付信息')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +305,7 @@ async function submitPayment() {
|
||||
})
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = error.message || '支付失败'
|
||||
errorMessage.value = error.message || legacyT('支付失败')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -315,8 +320,8 @@ async function submitPayment() {
|
||||
}
|
||||
|
||||
errorMessage.value = paymentIntent?.status
|
||||
? `当前支付状态: ${paymentIntent.status}`
|
||||
: '支付已提交,请稍后刷新订单状态'
|
||||
? `${legacyT('当前支付状态')}: ${paymentIntent.status}`
|
||||
: legacyT('支付已提交,请稍后刷新订单状态')
|
||||
} catch (error) {
|
||||
errorMessage.value = formatStripeError(error)
|
||||
} finally {
|
||||
@@ -370,18 +375,19 @@ function formatStripeError(error: unknown): string {
|
||||
return message.trim()
|
||||
}
|
||||
}
|
||||
return 'Stripe 支付处理失败'
|
||||
return legacyT('Stripe 支付处理失败')
|
||||
}
|
||||
|
||||
function paymentMethodTypeLabel(method: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
card: '银行卡/信用卡',
|
||||
alipay: '支付宝',
|
||||
wechat_pay: '微信支付',
|
||||
card: legacyT('银行卡/信用卡'),
|
||||
alipay: legacyT('支付宝'),
|
||||
wechat_pay: legacyT('微信支付'),
|
||||
link: 'Link',
|
||||
us_bank_account: '美国银行账户',
|
||||
us_bank_account: legacyT('美国银行账户'),
|
||||
}
|
||||
return labels[method] || paymentMethodLabel(method) || method
|
||||
const fallback = paymentMethodLabel(method)
|
||||
return labels[method] || (fallback ? legacyT(fallback) : method)
|
||||
}
|
||||
|
||||
function buildReturnUrl(): string {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="h-8 w-32 text-xs border-border/60"
|
||||
:class="[presetTriggerClass]"
|
||||
>
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
<SelectValue :placeholder="legacyT('选择时间段')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :searchable="false">
|
||||
<SelectItem
|
||||
@@ -29,7 +29,7 @@
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">至</span>
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('至') }}</span>
|
||||
<Input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
@@ -42,23 +42,23 @@
|
||||
v-model="selectedGranularity"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
||||
<SelectValue placeholder="粒度" />
|
||||
<SelectValue :placeholder="legacyT('粒度')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-if="allowHourly && canUseHourly"
|
||||
value="hour"
|
||||
>
|
||||
小时
|
||||
{{ legacyT('小时') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="day">
|
||||
天
|
||||
{{ legacyT('天') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="week">
|
||||
周
|
||||
{{ legacyT('周') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="month">
|
||||
月
|
||||
{{ legacyT('月') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
Input
|
||||
} from '@/components/ui'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
@@ -89,17 +90,18 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
}>()
|
||||
const { legacyT } = useI18n()
|
||||
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
||||
type SelectablePreset = typeof selectablePresets[number]
|
||||
|
||||
const presetLabels: Record<SelectablePreset, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
last7days: '最近7天',
|
||||
last30days: '最近30天',
|
||||
last90days: '最近90天',
|
||||
custom: '自定义'
|
||||
}
|
||||
const presetLabels = computed<Record<SelectablePreset, string>>(() => ({
|
||||
today: legacyT('今天'),
|
||||
yesterday: legacyT('昨天'),
|
||||
last7days: legacyT('最近7天'),
|
||||
last30days: legacyT('最近30天'),
|
||||
last90days: legacyT('最近90天'),
|
||||
custom: legacyT('自定义')
|
||||
}))
|
||||
|
||||
const activePresetOptions = computed<SelectablePreset[]>(() => {
|
||||
const unique = new Set(props.presetOptions)
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<!-- Reconnecting State -->
|
||||
<template v-if="updatePhase === 'reconnecting'">
|
||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||
正在重启服务
|
||||
{{ legacyT('正在重启服务') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground max-w-xs mt-2 mb-2">
|
||||
服务正在切换版本并重启,请稍候...
|
||||
{{ legacyT('服务正在切换版本并重启,请稍候...') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-primary mt-2 mb-4">
|
||||
<svg
|
||||
@@ -71,10 +71,10 @@
|
||||
v-if="publishedAt"
|
||||
class="mb-2 text-left text-xs text-muted-foreground"
|
||||
>
|
||||
发布于 {{ formattedPublishedAt }}
|
||||
{{ legacyT('发布于') }} {{ formattedPublishedAt }}
|
||||
</div>
|
||||
<div class="mb-2 text-left text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/80">
|
||||
更新内容
|
||||
{{ legacyT('更新内容') }}
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
@@ -96,7 +96,7 @@
|
||||
v-if="updatePhase === 'restart'"
|
||||
class="mt-1 text-xs text-primary"
|
||||
>
|
||||
更新包已下载,点击"立即重启"完成安装
|
||||
{{ legacyT('更新包已下载,点击"立即重启"完成安装') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -132,7 +132,7 @@
|
||||
class="mt-3 w-full max-w-sm rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-left"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
在 docker-compose.yml 所在目录执行
|
||||
{{ legacyT('在 docker-compose.yml 所在目录执行') }}
|
||||
</p>
|
||||
<code class="mt-1 block break-all rounded bg-background/70 px-2 py-1.5 font-mono text-xs text-foreground">
|
||||
{{ dockerUpdateCommand }}
|
||||
@@ -152,7 +152,7 @@
|
||||
:disabled="updating || rollingBack"
|
||||
@click="handleLater"
|
||||
>
|
||||
稍后提醒
|
||||
{{ legacyT('稍后提醒') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="rollbackAvailable"
|
||||
@@ -161,7 +161,7 @@
|
||||
:disabled="updating || rollingBack"
|
||||
@click="handleRollback"
|
||||
>
|
||||
{{ rollingBack ? '回滚中...' : '回滚上一版本' }}
|
||||
{{ rollingBack ? legacyT('回滚中...') : legacyT('回滚上一版本') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
@@ -194,6 +194,7 @@ import { formatDisplayVersion } from '@/utils/version'
|
||||
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { marked } from 'marked'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -224,6 +225,7 @@ const emit = defineEmits<{
|
||||
applyUpdate: []
|
||||
rollback: []
|
||||
}>()
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
|
||||
@@ -237,19 +239,19 @@ const updateStrategy = computed(() => props.updateStrategy ?? 'manual')
|
||||
const isDockerUpdate = computed(() => updateStrategy.value === 'docker' && !canApplyUpdate.value)
|
||||
const dockerUpdateCommand = computed(() => props.dockerUpdateCommand || '')
|
||||
const updateBlockerText = computed(() => {
|
||||
if (!updateSupported.value) return props.updateBlocker || SOURCE_BUILD_UPDATE_HINT
|
||||
return props.updateBlocker || '当前版本暂不支持在线更新'
|
||||
if (!updateSupported.value) return legacyT(props.updateBlocker || SOURCE_BUILD_UPDATE_HINT)
|
||||
return legacyT(props.updateBlocker || '当前版本暂不支持在线更新')
|
||||
})
|
||||
const reconnectMessage = computed(() => props.reconnectMessage ?? '等待服务恢复...')
|
||||
const reconnectMessage = computed(() => legacyT(props.reconnectMessage ?? '等待服务恢复...'))
|
||||
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
||||
const rollingBack = computed(() => props.rollingBack ?? false)
|
||||
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||
const dialogTitleText = computed(() => props.dialogTitle ?? '发现新版本')
|
||||
const versionLabelText = computed(() => props.versionLabel ?? '最新版本')
|
||||
const releaseLinkLabelText = computed(() => props.releaseLinkLabel ?? '查看发布')
|
||||
const downloadProgressText = computed(() => legacyT(props.downloadProgressText || '正在下载更新包...'))
|
||||
const dialogTitleText = computed(() => legacyT(props.dialogTitle ?? '发现新版本'))
|
||||
const versionLabelText = computed(() => legacyT(props.versionLabel ?? '最新版本'))
|
||||
const releaseLinkLabelText = computed(() => legacyT(props.releaseLinkLabel ?? '查看发布'))
|
||||
const fallbackDescriptionText = computed(() => {
|
||||
if (!canApplyUpdate.value) return updateBlockerText.value
|
||||
return '新版本已发布,建议更新以获得最新功能和安全修复'
|
||||
return legacyT('新版本已发布,建议更新以获得最新功能和安全修复')
|
||||
})
|
||||
const downloadProgressPercent = computed(() => {
|
||||
const value = props.downloadProgressPercent
|
||||
@@ -262,9 +264,9 @@ const progressBarWidth = computed(() => {
|
||||
})
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
return legacyT(updatePhase.value === 'restart' ? '重启中...' : '下载中...')
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
return legacyT(updatePhase.value === 'restart' ? '立即重启' : '立即更新')
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
@@ -279,7 +281,7 @@ const formattedPublishedAt = computed(() => {
|
||||
if (!props.publishedAt) return ''
|
||||
try {
|
||||
const date = new Date(props.publishedAt)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
return date.toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg transition"
|
||||
:class="buttonClass"
|
||||
:title="buttonTitle"
|
||||
aria-label="版本信息"
|
||||
:aria-label="$legacyT('版本信息')"
|
||||
>
|
||||
<Info
|
||||
v-if="!isReconnecting"
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-foreground">
|
||||
版本信息
|
||||
{{ $legacyT('版本信息') }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-[0.3em] text-muted-foreground">
|
||||
System version
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
当前版本
|
||||
{{ $legacyT('当前版本') }}
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-foreground">
|
||||
{{ currentVersionLabel }}
|
||||
@@ -59,7 +59,7 @@
|
||||
class="mt-2"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最新版本
|
||||
{{ $legacyT('最新版本') }}
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-sm text-foreground">
|
||||
{{ latestVersionLabel }}
|
||||
@@ -71,7 +71,7 @@
|
||||
v-if="status?.error"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
检查更新失败:{{ status.error }}
|
||||
{{ $legacyT('检查更新失败:') }}{{ $legacyT(status.error) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
@@ -87,7 +87,7 @@
|
||||
class="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-primary"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
|
||||
<span class="text-xs font-medium">服务重启中,请稍候...</span>
|
||||
<span class="text-xs font-medium">{{ $legacyT('服务重启中,请稍候...') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -126,7 +126,7 @@
|
||||
class="mr-2 h-3.5 w-3.5"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
重新检查
|
||||
{{ $legacyT('重新检查') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="rollbackAvailable"
|
||||
@@ -173,7 +173,7 @@
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="showReleases ? 'rotate-90' : ''"
|
||||
/>
|
||||
历史版本
|
||||
{{ $legacyT('历史版本') }}
|
||||
<span
|
||||
v-if="releases.length > 0"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
@@ -209,15 +209,15 @@
|
||||
<span
|
||||
v-if="release.is_current"
|
||||
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>当前</span>
|
||||
>{{ $legacyT('当前') }}</span>
|
||||
<span
|
||||
v-else-if="release.is_newer"
|
||||
class="shrink-0 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>新</span>
|
||||
>{{ $legacyT('新') }}</span>
|
||||
<span
|
||||
v-if="release.is_newer && release.updatable === false"
|
||||
class="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>不可在线更新</span>
|
||||
>{{ $legacyT('不可在线更新') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="release.published_at"
|
||||
@@ -233,14 +233,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<span class="ml-2 shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
详情
|
||||
{{ $legacyT('详情') }}
|
||||
</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="!loadingReleases && releases.length === 0 && !releasesError"
|
||||
class="py-2 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无版本信息
|
||||
{{ $legacyT('暂无版本信息') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,19 +267,19 @@
|
||||
v-if="selectedRelease.is_current"
|
||||
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
当前运行版本
|
||||
{{ $legacyT('当前运行版本') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedRelease.is_newer"
|
||||
class="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
可升级版本
|
||||
{{ $legacyT('可升级版本') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
历史版本
|
||||
{{ $legacyT('历史版本') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
v-else
|
||||
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
这个版本没有附带更新说明。
|
||||
{{ $legacyT('这个版本没有附带更新说明。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
variant="outline"
|
||||
@click="showReleaseDetails = false"
|
||||
>
|
||||
关闭
|
||||
{{ $legacyT('关闭') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedRelease?.release_url"
|
||||
@@ -316,7 +316,7 @@
|
||||
@click="handleOpenSelectedReleasePage"
|
||||
>
|
||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||
查看标签页
|
||||
{{ $legacyT('查看标签页') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUseSelectedRelease"
|
||||
@@ -342,6 +342,7 @@ import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||
import { formatDisplayVersion } from '@/utils/version'
|
||||
import { describeUpdateStatus } from '@/utils/updateStatus'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { marked } from 'marked'
|
||||
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
@@ -363,6 +364,7 @@ const emit = defineEmits<{
|
||||
previewRelease: [release: ReleaseEntry]
|
||||
rollback: []
|
||||
}>()
|
||||
const { legacyT, locale } = useI18n()
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
|
||||
@@ -385,7 +387,7 @@ const isReconnecting = computed(() => updatePhase.value === 'reconnecting')
|
||||
const isDownloadingUpdate = computed(() => updating.value && updatePhase.value === 'download')
|
||||
const isBusy = computed(() => updating.value || rollingBack.value || isReconnecting.value)
|
||||
const canApplyUpdate = computed(() => updateSupported.value && props.status?.updatable !== false)
|
||||
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||
const downloadProgressText = computed(() => legacyT(props.downloadProgressText || '正在下载更新包...'))
|
||||
const downloadProgressPercent = computed(() => {
|
||||
const value = props.downloadProgressPercent
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
@@ -397,11 +399,11 @@ const progressBarWidth = computed(() => {
|
||||
})
|
||||
const updateBlockerText = computed(() => {
|
||||
if (!updateSupported.value) {
|
||||
return props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT
|
||||
return legacyT(props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT)
|
||||
}
|
||||
return props.status?.update_blocker || '当前版本暂不支持在线更新'
|
||||
return legacyT(props.status?.update_blocker || '当前版本暂不支持在线更新')
|
||||
})
|
||||
const releaseButtonLabel = computed(() => updateSupported.value ? '查看更新' : '查看发布')
|
||||
const releaseButtonLabel = computed(() => legacyT(updateSupported.value ? '查看更新' : '查看发布'))
|
||||
const buttonClass = computed(() => {
|
||||
const classes = []
|
||||
|
||||
@@ -427,15 +429,15 @@ const buttonClass = computed(() => {
|
||||
return classes
|
||||
})
|
||||
const statusLabel = computed(() => {
|
||||
if (isReconnecting.value) return '重启中'
|
||||
if (rollingBack.value) return '回滚中'
|
||||
if (updating.value) return '更新中'
|
||||
return describeUpdateStatus(props.status)
|
||||
if (isReconnecting.value) return legacyT('重启中')
|
||||
if (rollingBack.value) return legacyT('回滚中')
|
||||
if (updating.value) return legacyT('更新中')
|
||||
return legacyT(describeUpdateStatus(props.status))
|
||||
})
|
||||
const currentVersionLabel = computed(() => {
|
||||
return props.status?.current_version
|
||||
? formatDisplayVersion(props.status.current_version)
|
||||
: '加载中...'
|
||||
: legacyT('加载中...')
|
||||
})
|
||||
const latestVersionLabel = computed(() => {
|
||||
return props.status?.latest_version
|
||||
@@ -452,25 +454,25 @@ const statusPillClass = computed(() => {
|
||||
return 'border-border/60 bg-background/70 text-muted-foreground'
|
||||
})
|
||||
const buttonTitle = computed(() => {
|
||||
if (isReconnecting.value) return '服务重启中...'
|
||||
if (!props.status) return '版本信息'
|
||||
return `版本信息:${statusLabel.value}`
|
||||
if (isReconnecting.value) return legacyT('服务重启中...')
|
||||
if (!props.status) return legacyT('版本信息')
|
||||
return `${legacyT('版本信息:')}${statusLabel.value}`
|
||||
})
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
return legacyT(updatePhase.value === 'restart' ? '重启中...' : '下载中...')
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
return legacyT(updatePhase.value === 'restart' ? '立即重启' : '立即更新')
|
||||
})
|
||||
const selectedReleaseTitle = computed(() => {
|
||||
return selectedRelease.value
|
||||
? `版本详情 · ${formatDisplayVersion(selectedRelease.value.version)}`
|
||||
: '版本详情'
|
||||
? `${legacyT('版本详情')} · ${formatDisplayVersion(selectedRelease.value.version)}`
|
||||
: legacyT('版本详情')
|
||||
})
|
||||
const selectedReleaseDescription = computed(() => {
|
||||
return selectedRelease.value?.published_at
|
||||
? `发布于 ${formatDate(selectedRelease.value.published_at)}`
|
||||
: '查看该版本的发布说明'
|
||||
? `${legacyT('发布于')} ${formatDate(selectedRelease.value.published_at)}`
|
||||
: legacyT('查看该版本的发布说明')
|
||||
})
|
||||
const canUseSelectedRelease = computed(() => {
|
||||
return !!selectedRelease.value &&
|
||||
@@ -479,19 +481,19 @@ const canUseSelectedRelease = computed(() => {
|
||||
updateSupported.value
|
||||
})
|
||||
const selectedReleaseActionLabel = computed(() => {
|
||||
if (!selectedRelease.value) return '切换到此版本'
|
||||
return selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本'
|
||||
if (!selectedRelease.value) return legacyT('切换到此版本')
|
||||
return legacyT(selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本')
|
||||
})
|
||||
const selectedReleaseHelpText = computed(() => {
|
||||
if (!selectedRelease.value) return ''
|
||||
if (selectedRelease.value.is_current) return '当前正在运行这个版本。'
|
||||
if (selectedRelease.value.is_current) return legacyT('当前正在运行这个版本。')
|
||||
if (!updateSupported.value) {
|
||||
return selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT
|
||||
return legacyT(selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT)
|
||||
}
|
||||
if (selectedRelease.value.update_blocker) return selectedRelease.value.update_blocker
|
||||
return selectedRelease.value.is_newer
|
||||
if (selectedRelease.value.update_blocker) return legacyT(selectedRelease.value.update_blocker)
|
||||
return legacyT(selectedRelease.value.is_newer
|
||||
? '将这个版本作为在线更新目标。'
|
||||
: '将切换到这个历史版本。'
|
||||
: '将切换到这个历史版本。')
|
||||
})
|
||||
const selectedReleaseDisplayNotes = computed(() => {
|
||||
return normalizeReleaseNotesForDisplay(selectedRelease.value?.release_notes)
|
||||
@@ -516,7 +518,7 @@ const selectedReleaseNotesHtml = computed(() => {
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
return date.toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -533,10 +535,10 @@ async function fetchReleases(force = false) {
|
||||
try {
|
||||
const data = await adminApi.getSystemReleases(force)
|
||||
releases.value = data.releases
|
||||
releasesError.value = data.error
|
||||
releasesError.value = data.error ? legacyT(data.error) : null
|
||||
releasesFetched = true
|
||||
} catch (err) {
|
||||
releasesError.value = err instanceof Error ? err.message : '获取版本列表失败'
|
||||
releasesError.value = legacyT(err instanceof Error ? err.message : '获取版本列表失败')
|
||||
} finally {
|
||||
loadingReleases.value = false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 左侧:记录范围和每页数量 -->
|
||||
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
|
||||
<span class="font-medium whitespace-nowrap">
|
||||
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> 条,共 <span class="text-foreground font-semibold">{{ total }}</span> 条
|
||||
{{ rangeSummary }}
|
||||
</span>
|
||||
<Select
|
||||
v-if="showPageSizeSelector"
|
||||
@@ -21,7 +21,7 @@
|
||||
:key="size"
|
||||
:value="String(size)"
|
||||
>
|
||||
{{ size }} 条/页
|
||||
{{ pageSizeLabel(size) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -55,7 +55,7 @@
|
||||
v-if="totalPages > 7"
|
||||
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span class="hidden sm:inline">跳至</span>
|
||||
<span class="hidden sm:inline">{{ jumpToLabel }}</span>
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
type="text"
|
||||
@@ -66,7 +66,7 @@
|
||||
@blur="handleJumpPage"
|
||||
@input="filterNumericInput"
|
||||
>
|
||||
<span class="hidden sm:inline">页</span>
|
||||
<span class="hidden sm:inline">{{ pageLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,6 +75,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface Props {
|
||||
current: number
|
||||
@@ -99,8 +100,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const jumpPageInput = ref('')
|
||||
const locale = useI18n().locale
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
|
||||
|
||||
@@ -110,6 +113,20 @@ const recordRange = computed(() => {
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const rangeSummary = computed(() => {
|
||||
if (locale.value === 'en-US') {
|
||||
return `Showing ${recordRange.value.start}-${recordRange.value.end} of ${props.total} items`
|
||||
}
|
||||
return `显示 ${recordRange.value.start}-${recordRange.value.end} 条,共 ${props.total} 条`
|
||||
})
|
||||
|
||||
const jumpToLabel = computed(() => locale.value === 'en-US' ? 'Go to' : '跳至')
|
||||
const pageLabel = computed(() => locale.value === 'en-US' ? 'page' : '页')
|
||||
|
||||
function pageSizeLabel(size: number): string {
|
||||
return locale.value === 'en-US' ? `${size} / page` : `${size} 条/页`
|
||||
}
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const total = totalPages.value
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { translateLegacyText } from '@/i18n/messages'
|
||||
|
||||
export type ConfirmVariant = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
|
||||
|
||||
@@ -25,6 +27,10 @@ const state = ref<ConfirmState>({
|
||||
})
|
||||
|
||||
export function useConfirm() {
|
||||
function localizeConfirmText(value: string): string {
|
||||
return translateLegacyText(value, getI18nLocale())
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认对话框
|
||||
* @param options 对话框选项
|
||||
@@ -34,10 +40,10 @@ export function useConfirm() {
|
||||
return new Promise((resolve) => {
|
||||
state.value = {
|
||||
isOpen: true,
|
||||
title: options.title || '确认操作',
|
||||
message: options.message,
|
||||
confirmText: options.confirmText || '确认',
|
||||
cancelText: options.cancelText || '取消',
|
||||
title: localizeConfirmText(options.title || '确认操作'),
|
||||
message: localizeConfirmText(options.message),
|
||||
confirmText: localizeConfirmText(options.confirmText || '确认'),
|
||||
cancelText: localizeConfirmText(options.cancelText || '取消'),
|
||||
variant: options.variant || 'question',
|
||||
resolve
|
||||
}
|
||||
@@ -109,4 +115,4 @@ export function useConfirm() {
|
||||
handleConfirm,
|
||||
handleCancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { TOAST_CONFIG } from '@/config/constants'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { translateLegacyText } from '@/i18n/messages'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
@@ -11,15 +13,31 @@ export interface Toast {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type ToastOptions = Omit<Toast, 'id' | 'variant'> & {
|
||||
description?: string
|
||||
variant?: ToastVariant | 'destructive'
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
export function useToast() {
|
||||
function showToast(options: Omit<Toast, 'id'>) {
|
||||
function localizeToastText(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : translateLegacyText(value, getI18nLocale())
|
||||
}
|
||||
|
||||
function normalizeToastVariant(variant: ToastOptions['variant']): ToastVariant {
|
||||
return variant === 'destructive' ? 'error' : variant || 'info'
|
||||
}
|
||||
|
||||
function showToast(options: ToastOptions) {
|
||||
const { description, ...toastOptions } = options
|
||||
const toast: Toast = {
|
||||
id: Date.now().toString(),
|
||||
variant: 'info',
|
||||
duration: 5000,
|
||||
...options
|
||||
...toastOptions,
|
||||
variant: normalizeToastVariant(options.variant),
|
||||
title: localizeToastText(options.title),
|
||||
message: localizeToastText(options.message ?? description),
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +84,11 @@ export function useToast() {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
toast: showToast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { defaultPlatformOptions, getInstallCommand, platformPresets } from '@/config/platform-presets'
|
||||
|
||||
describe('platform presets', () => {
|
||||
it('keeps the shared default options as the default preset source', () => {
|
||||
expect(platformPresets.default.options).toBe(defaultPlatformOptions)
|
||||
expect(platformPresets.default.defaultValue).toBe('mac')
|
||||
})
|
||||
|
||||
it('resolves install commands from the shared preset table', () => {
|
||||
expect(getInstallCommand('claude', 'nodejs')).toBe('npm install -g @anthropic-ai/claude-code')
|
||||
expect(getInstallCommand('codex', 'homebrew')).toBe('brew install --cask codex')
|
||||
expect(getInstallCommand('gemini', 'nodejs')).toBe('npm install -g @google/gemini-cli')
|
||||
})
|
||||
|
||||
it('returns an empty command for an unknown option value', () => {
|
||||
expect(getInstallCommand('claude', 'unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Apple, Box, Monitor, Terminal } from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
import type { MessageKey } from '@/i18n'
|
||||
|
||||
export interface PlatformOption {
|
||||
value: string
|
||||
labelKey: MessageKey
|
||||
hintKey: MessageKey
|
||||
icon: Component
|
||||
command: string
|
||||
}
|
||||
|
||||
export const defaultPlatformOptions: PlatformOption[] = [
|
||||
{ value: 'mac', labelKey: 'platform.macLinux', hintKey: 'platform.terminal', icon: Terminal, command: '' },
|
||||
{ value: 'windows', labelKey: 'platform.windows', hintKey: 'platform.powershell', icon: Monitor, command: '' }
|
||||
]
|
||||
|
||||
export const platformPresets = {
|
||||
default: {
|
||||
options: defaultPlatformOptions,
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
claude: {
|
||||
options: [
|
||||
{ value: 'mac', labelKey: 'platform.macLinux', hintKey: 'platform.terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
|
||||
{ value: 'windows', labelKey: 'platform.windows', hintKey: 'platform.powershell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
|
||||
{ value: 'nodejs', labelKey: 'platform.nodejs', hintKey: 'platform.npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
|
||||
{ value: 'homebrew', labelKey: 'platform.mac', hintKey: 'platform.homebrew', icon: Apple, command: 'brew install --cask claude-code' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
codex: {
|
||||
options: [
|
||||
{ value: 'nodejs', labelKey: 'platform.nodejs', hintKey: 'platform.npm', icon: Box, command: 'npm install -g @openai/codex' },
|
||||
{ value: 'homebrew', labelKey: 'platform.mac', hintKey: 'platform.homebrew', icon: Apple, command: 'brew install --cask codex' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
},
|
||||
gemini: {
|
||||
options: [
|
||||
{ value: 'nodejs', labelKey: 'platform.nodejs', hintKey: 'platform.npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
|
||||
{ value: 'homebrew', labelKey: 'platform.mac', hintKey: 'platform.homebrew', icon: Apple, command: 'brew install gemini-cli' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
}
|
||||
} as const
|
||||
|
||||
export function getInstallCommand(preset: keyof typeof platformPresets, value: string): string {
|
||||
const config = platformPresets[preset]
|
||||
return config.options.find((opt) => opt.value === value)?.command ?? ''
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
class="h-16 w-16 mb-4"
|
||||
>
|
||||
<h2 class="text-2xl font-semibold text-foreground">
|
||||
登录到 {{ siteName }}
|
||||
{{ t('auth.login.title', { siteName }) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
class="rounded-lg border border-primary/20 bg-primary/5 p-3 mb-5"
|
||||
>
|
||||
<p class="text-xs font-medium text-foreground mb-2">
|
||||
演示模式
|
||||
{{ t('auth.login.demoMode') }}
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
@@ -66,7 +66,7 @@
|
||||
v-html="getOAuthIcon(oauthProviders[0].provider_type, oauthProviders[0].icon_url)"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
|
||||
<span>{{ t('auth.login.oauthWithProvider', { provider: oauthProviders[0].display_name }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
v-else
|
||||
class="flex flex-col items-center gap-3"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">使用以下方式登录</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('auth.login.oauthOptions') }}</span>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="p in oauthProviders"
|
||||
@@ -102,7 +102,7 @@
|
||||
class="flex items-center gap-3 mb-5"
|
||||
>
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
<span class="text-xs text-muted-foreground px-2">或使用账号密码</span>
|
||||
<span class="text-xs text-muted-foreground px-2">{{ t('auth.login.accountPassword') }}</span>
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
:class="[authType === 'local' && 'active']"
|
||||
@click="authType = 'local'"
|
||||
>
|
||||
本地登录
|
||||
{{ t('auth.login.local') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,7 +125,7 @@
|
||||
:class="[authType === 'ldap' && 'active']"
|
||||
@click="authType = 'ldap'"
|
||||
>
|
||||
LDAP 登录
|
||||
{{ t('auth.login.ldap') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
@click="authType = 'local'"
|
||||
>
|
||||
管理员本地登录
|
||||
{{ t('auth.login.adminLocal') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="ldapExclusive && authType === 'local'"
|
||||
@@ -162,7 +162,7 @@
|
||||
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
@click="authType = 'ldap'"
|
||||
>
|
||||
返回 LDAP 登录
|
||||
{{ t('auth.login.backToLdap') }}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
@@ -171,7 +171,7 @@
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
placeholder="用户名或邮箱"
|
||||
:placeholder="t('auth.login.usernamePlaceholder')"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
@@ -184,7 +184,7 @@
|
||||
for="password"
|
||||
class="text-sm"
|
||||
>
|
||||
密码
|
||||
{{ t('auth.login.password') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -192,7 +192,7 @@
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="输入密码"
|
||||
:placeholder="t('auth.login.passwordPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
:disable-autofill="false"
|
||||
/>
|
||||
@@ -204,7 +204,7 @@
|
||||
:disabled="authStore.loading"
|
||||
class="w-full h-12"
|
||||
>
|
||||
{{ authStore.loading ? '登录中...' : '登录' }}
|
||||
{{ authStore.loading ? t('auth.login.submitting') : t('auth.login.submit') }}
|
||||
</Button>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
@@ -212,7 +212,7 @@
|
||||
v-if="!isDemo && !allowRegistration"
|
||||
class="text-xs text-muted-foreground text-center"
|
||||
>
|
||||
如需开通账户,请联系管理员
|
||||
{{ t('auth.login.contactAdmin') }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -221,13 +221,13 @@
|
||||
v-if="allowRegistration"
|
||||
class="mt-5 pt-5 border-t border-border text-center text-sm text-muted-foreground"
|
||||
>
|
||||
还没有账户?
|
||||
{{ t('auth.login.noAccount') }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
@click="handleSwitchToRegister"
|
||||
>
|
||||
立即注册
|
||||
{{ t('auth.login.registerNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,6 +266,7 @@ import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { getApiUrl } from '@/utils/url'
|
||||
import { getOAuthIcon } from '@/utils/oauth-icons'
|
||||
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -280,6 +281,7 @@ const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
|
||||
const { siteName } = useSiteInfo()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
@@ -321,7 +323,7 @@ const showAuthTypeTabs = computed(() => {
|
||||
})
|
||||
|
||||
const emailLabel = computed(() => {
|
||||
return '用户名/邮箱'
|
||||
return t('auth.login.usernameEmail')
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
@@ -354,7 +356,7 @@ async function handleLogin(event?: Event) {
|
||||
const { email, password } = readCurrentLoginCredentials(event)
|
||||
|
||||
if (!email || !password) {
|
||||
showWarning('请输入邮箱和密码')
|
||||
showWarning(t('auth.login.required'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -364,12 +366,12 @@ async function handleLogin(event?: Event) {
|
||||
|
||||
await navigateAfterLogin(router, targetPath)
|
||||
|
||||
showSuccess('登录成功,正在跳转...')
|
||||
showSuccess(t('auth.login.successRedirecting'))
|
||||
|
||||
// 关闭对话框
|
||||
isOpen.value = false
|
||||
} else {
|
||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
||||
showError(authStore.error || t('auth.login.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +425,7 @@ function handleSwitchToRegister() {
|
||||
|
||||
function handleRegisterSuccess() {
|
||||
showRegisterDialog.value = false
|
||||
showSuccess('注册成功!请登录')
|
||||
showSuccess(t('auth.register.successLogin'))
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
注册新账户
|
||||
{{ registerUi.title }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ emailConfigured ? '请填写您的信息完成注册' : '请填写用户名和密码完成注册' }}
|
||||
{{ emailConfigured ? registerUi.fillEmailInfo : registerUi.fillBasicInfo }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label for="reg-email">
|
||||
邮箱
|
||||
{{ registerUi.email }}
|
||||
<span
|
||||
v-if="requireEmailVerification"
|
||||
class="text-destructive"
|
||||
@@ -42,7 +42,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
>(可选)</span>
|
||||
>{{ registerUi.optional }}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reg-email"
|
||||
@@ -59,7 +59,7 @@
|
||||
v-if="turnstileRequired"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>人机验证 <span class="text-destructive">*</span></Label>
|
||||
<Label>{{ registerUi.turnstile }} <span class="text-destructive">*</span></Label>
|
||||
<TurnstileWidget
|
||||
ref="turnstileWidgetRef"
|
||||
v-model="turnstileToken"
|
||||
@@ -76,7 +76,7 @@
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>验证码 <span class="text-destructive">*</span></Label>
|
||||
<Label>{{ registerUi.verificationCode }} <span class="text-destructive">*</span></Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@@ -143,12 +143,12 @@
|
||||
|
||||
<!-- Username -->
|
||||
<div class="space-y-2">
|
||||
<Label for="reg-uname">用户名 <span class="text-destructive">*</span></Label>
|
||||
<Label for="reg-uname">{{ registerUi.username }} <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="reg-uname"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
:placeholder="registerUi.usernamePlaceholder"
|
||||
required
|
||||
disable-autofill
|
||||
:disabled="isLoading"
|
||||
@@ -164,7 +164,9 @@
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label :for="`pwd-${formNonce}`">密码 <span class="text-destructive">*</span></Label>
|
||||
<Label :for="`pwd-${formNonce}`">
|
||||
{{ registerUi.password }} <span class="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
:id="`pwd-${formNonce}`"
|
||||
v-model="formData.password"
|
||||
@@ -192,7 +194,7 @@
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-destructive">*</span></Label>
|
||||
<Label :for="`pwd-confirm-${formNonce}`">{{ registerUi.confirmPassword }} <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="formData.confirmPassword"
|
||||
@@ -200,7 +202,7 @@
|
||||
autocomplete="new-password"
|
||||
disable-autofill
|
||||
:name="`pwd-confirm-${formNonce}`"
|
||||
placeholder="再次输入密码"
|
||||
:placeholder="registerUi.confirmPasswordPlaceholder"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
@@ -208,7 +210,7 @@
|
||||
v-if="formData.confirmPassword && formData.password !== formData.confirmPassword"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
{{ registerUi.passwordMismatch }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +218,7 @@
|
||||
v-if="inviteCode"
|
||||
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
已识别邀请码 <span class="font-mono font-semibold text-foreground">{{ inviteCode }}</span>
|
||||
{{ inviteCodeText }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -230,38 +232,38 @@
|
||||
@update:checked="privacyAccepted = !!$event"
|
||||
/>
|
||||
<span class="leading-6">
|
||||
我已阅读并同意
|
||||
{{ registerUi.privacyPrefix }}
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-primary underline-offset-4 hover:underline"
|
||||
@click="privacyDialogOpen = true"
|
||||
>
|
||||
隐私政策
|
||||
{{ registerUi.privacyTitle }}
|
||||
</button>
|
||||
<RouterLink
|
||||
to="/privacy-policy"
|
||||
target="_blank"
|
||||
class="ml-1 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
新窗口打开
|
||||
{{ registerUi.openInNewWindow }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
当前版本:{{ privacyPolicyVersion }}
|
||||
{{ privacyVersionText }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="text-center text-sm">
|
||||
已有账户?
|
||||
{{ registerUi.hasAccount }}
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-auto p-0"
|
||||
@click="handleSwitchToLogin"
|
||||
>
|
||||
立即登录
|
||||
{{ registerUi.switchToLogin }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,14 +276,14 @@
|
||||
:disabled="isLoading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ registerUi.cancel }}
|
||||
</Button>
|
||||
<Button
|
||||
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
||||
:disabled="isLoading || !canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isLoading ? loadingText : '注册' }}
|
||||
{{ isLoading ? loadingText : registerUi.submit }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -289,7 +291,7 @@
|
||||
<Dialog
|
||||
v-model="privacyDialogOpen"
|
||||
size="2xl"
|
||||
title="隐私政策"
|
||||
:title="registerUi.privacyTitle"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
@@ -302,7 +304,7 @@
|
||||
type="button"
|
||||
@click="privacyDialogOpen = false"
|
||||
>
|
||||
我知道了
|
||||
{{ registerUi.acknowledge }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -314,6 +316,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
import { authApi, type RegisterRequest, type RegistrationPrivacyPolicySettings } from '@/api/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import {
|
||||
@@ -365,6 +368,35 @@ interface Emits {
|
||||
}
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const registerUi = computed(() => ({
|
||||
title: t('auth.register.title'),
|
||||
fillEmailInfo: t('auth.register.fillEmailInfo'),
|
||||
fillBasicInfo: t('auth.register.fillBasicInfo'),
|
||||
email: t('auth.register.email'),
|
||||
optional: t('auth.register.optional'),
|
||||
turnstile: t('auth.register.turnstile'),
|
||||
verificationCode: t('auth.register.verificationCode'),
|
||||
username: t('auth.register.username'),
|
||||
usernamePlaceholder: t('auth.register.usernamePlaceholder'),
|
||||
password: t('auth.register.password'),
|
||||
confirmPassword: t('auth.register.confirmPassword'),
|
||||
confirmPasswordPlaceholder: t('auth.register.confirmPasswordPlaceholder'),
|
||||
passwordMismatch: t('auth.register.passwordMismatch'),
|
||||
inviteCode: (code: string) => t('auth.register.inviteCode', { code }),
|
||||
privacyPrefix: t('auth.register.privacyPrefix'),
|
||||
privacyTitle: t('site.privacy.title'),
|
||||
openInNewWindow: t('auth.register.openInNewWindow'),
|
||||
hasAccount: t('auth.register.hasAccount'),
|
||||
switchToLogin: t('auth.register.switchToLogin'),
|
||||
submit: t('auth.register.submit'),
|
||||
acknowledge: t('auth.register.acknowledge'),
|
||||
cancel: t('common.cancel'),
|
||||
}))
|
||||
|
||||
const inviteCodeText = computed(() => inviteCode.value ? registerUi.value.inviteCode(inviteCode.value) : '')
|
||||
const privacyVersionText = computed(() => t('site.privacy.currentVersion', { version: privacyPolicyVersion.value }))
|
||||
|
||||
// Form nonce for password fields (prevent autofill)
|
||||
const formNonce = ref(createFormNonce())
|
||||
@@ -468,7 +500,7 @@ const formData = ref({
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const loadingText = ref('注册中...')
|
||||
const loadingText = ref(t('auth.register.submit'))
|
||||
const isSendingCode = ref(false)
|
||||
const emailVerified = ref(false)
|
||||
const verificationError = ref(false)
|
||||
@@ -494,7 +526,7 @@ const resetTurnstile = () => {
|
||||
}
|
||||
|
||||
const handleTurnstileError = (message: string) => {
|
||||
showError(message, '人机验证失败')
|
||||
showError(message, t('auth.register.turnstile'))
|
||||
}
|
||||
|
||||
const inviteCode = ref<string | null>(null)
|
||||
@@ -504,7 +536,7 @@ const privacyPolicyEnabled = computed(() => !!props.privacyPolicy?.enabled)
|
||||
const privacyPolicyVersion = computed(() => props.privacyPolicy?.version || '1')
|
||||
const renderedPrivacyPolicy = computed(() => {
|
||||
const policy = props.privacyPolicy
|
||||
if (!policy?.content) return '<p>暂无隐私政策内容</p>'
|
||||
if (!policy?.content) return `<p>${t('site.privacy.empty')}</p>`
|
||||
if (policy.format === 'html') {
|
||||
return sanitizeHtml(policy.content)
|
||||
}
|
||||
@@ -536,28 +568,28 @@ const canSendCode = computed(() => {
|
||||
})
|
||||
|
||||
const sendCodeButtonText = computed(() => {
|
||||
if (isSendingCode.value) return '发送中...'
|
||||
if (emailVerified.value) return '验证成功'
|
||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||
if (isSendingCode.value) return t('auth.register.sendingCode')
|
||||
if (emailVerified.value) return t('auth.register.verified')
|
||||
if (cooldownSeconds.value > 0) return t('auth.register.retryAfterSeconds', { seconds: cooldownSeconds.value })
|
||||
if (
|
||||
turnstileRequired.value &&
|
||||
currentTurnstileAction.value === 'send_verification_code' &&
|
||||
!turnstileToken.value
|
||||
) return '请先完成人机验证'
|
||||
if (codeSentAt.value) return '重新发送验证码'
|
||||
return '发送验证码'
|
||||
) return t('auth.register.completeTurnstile')
|
||||
if (codeSentAt.value) return t('auth.register.resendCode')
|
||||
return t('auth.register.sendCode')
|
||||
})
|
||||
|
||||
const sendCodeLoadingText = computed(() => '正在发送验证码...')
|
||||
const sendCodeLoadingText = computed(() => t('auth.register.sendingCode'))
|
||||
|
||||
// 用户名验证
|
||||
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
||||
const usernameError = computed(() => {
|
||||
const username = formData.value.username.trim()
|
||||
if (!username) return ''
|
||||
if (username.length < 3) return '用户名长度至少为3个字符'
|
||||
if (username.length > 30) return '用户名长度不能超过30个字符'
|
||||
if (!usernameRegex.test(username)) return '用户名只能包含字母、数字、下划线、连字符和点号'
|
||||
if (username.length < 3) return t('auth.register.usernameTooShort')
|
||||
if (username.length > 30) return t('auth.register.usernameTooLong')
|
||||
if (!usernameRegex.test(username)) return t('auth.register.usernameInvalid')
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -743,14 +775,14 @@ const resetForm = () => {
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.value.email) {
|
||||
showError('请输入邮箱')
|
||||
showError(t('auth.register.emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(formData.value.email)) {
|
||||
showError('请输入有效的邮箱地址', '邮箱格式错误')
|
||||
showError(t('auth.register.emailInvalid'), t('auth.register.emailFormatError'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -769,7 +801,7 @@ const handleSendCode = async () => {
|
||||
expireMinutes.value = response.expire_minutes
|
||||
}
|
||||
|
||||
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
|
||||
success(t('auth.register.codeSent', { minutes: expireMinutes.value }), t('auth.register.codeSentTitle'))
|
||||
|
||||
// Start 60 second cooldown
|
||||
startCooldown(60)
|
||||
@@ -780,11 +812,11 @@ const handleSendCode = async () => {
|
||||
})
|
||||
} else {
|
||||
resetTurnstile()
|
||||
showError(response.message || '请稍后重试', '发送失败')
|
||||
showError(response.message || t('auth.register.tryAgainLater'), t('auth.register.sendFailed'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
resetTurnstile()
|
||||
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
||||
showError(parseApiError(error, t('auth.register.networkRetry')), t('auth.register.sendFailed'))
|
||||
} finally {
|
||||
isSendingCode.value = false
|
||||
resetTurnstile()
|
||||
@@ -798,7 +830,7 @@ const handleCodeComplete = async (code: string) => {
|
||||
if (emailVerified.value) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '验证中...'
|
||||
loadingText.value = t('auth.register.verifying')
|
||||
verificationError.value = false
|
||||
|
||||
try {
|
||||
@@ -806,16 +838,16 @@ const handleCodeComplete = async (code: string) => {
|
||||
|
||||
if (response.success) {
|
||||
emailVerified.value = true
|
||||
success('邮箱验证通过,请继续完成注册', '验证成功')
|
||||
success(t('auth.register.emailVerified'), t('auth.register.verifySuccess'))
|
||||
} else {
|
||||
verificationError.value = true
|
||||
showError(response.message || '验证码错误', '验证失败')
|
||||
showError(response.message || t('auth.register.codeInvalid'), t('auth.register.verifyFailed'))
|
||||
// Clear the code input
|
||||
clearCodeInputs()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
verificationError.value = true
|
||||
showError(parseApiError(error, '验证码错误,请重试'), '验证失败')
|
||||
showError(parseApiError(error, t('auth.register.codeRetry')), t('auth.register.verifyFailed'))
|
||||
// Clear the code input
|
||||
clearCodeInputs()
|
||||
} finally {
|
||||
@@ -826,18 +858,18 @@ const handleCodeComplete = async (code: string) => {
|
||||
const handleSubmit = async () => {
|
||||
// Validate password match
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
showError('两次输入的密码不一致', '密码不匹配')
|
||||
showError(t('auth.register.passwordMismatch'), t('auth.register.passwordError'))
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordError.value) {
|
||||
showError(passwordError.value, '密码错误')
|
||||
showError(passwordError.value, t('auth.register.passwordError'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check email verification if required
|
||||
if (props.requireEmailVerification && !emailVerified.value) {
|
||||
showError('请先完成邮箱验证')
|
||||
showError(t('auth.register.completeEmailVerification'))
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -845,17 +877,17 @@ const handleSubmit = async () => {
|
||||
currentTurnstileAction.value === 'register' &&
|
||||
!turnstileToken.value
|
||||
) {
|
||||
showError('请先完成人机验证')
|
||||
showError(t('auth.register.completeTurnstile'))
|
||||
return
|
||||
}
|
||||
|
||||
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
|
||||
showError('请先阅读并同意隐私政策')
|
||||
showError(t('auth.register.agreePrivacy'))
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '注册中...'
|
||||
loadingText.value = t('auth.register.submitting')
|
||||
|
||||
try {
|
||||
// 构建请求数据:邮箱可选
|
||||
@@ -880,13 +912,13 @@ const handleSubmit = async () => {
|
||||
|
||||
const response = await authApi.register(registerData)
|
||||
|
||||
success(response.message || '欢迎加入!请登录以继续', '注册成功')
|
||||
success(response.message || t('auth.register.successMessage'), t('auth.register.successTitle'))
|
||||
|
||||
emit('success')
|
||||
isOpen.value = false
|
||||
} catch (error: unknown) {
|
||||
resetTurnstile()
|
||||
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
||||
showError(parseApiError(error, t('auth.register.submitRetry')), t('auth.register.submitFailed'))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
resetTurnstile()
|
||||
|
||||
@@ -183,16 +183,17 @@ describe('RegisterDialog Turnstile verification flow', () => {
|
||||
emailInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
const sendButtonBeforeToken = Array.from(root.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('请先完成人机验证')) as HTMLButtonElement
|
||||
expect(sendButtonBeforeToken.disabled).toBe(true)
|
||||
const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[]
|
||||
const sendButtonBeforeToken = buttons.find((button) => button.type === 'button' && button.disabled && !button.dataset.testid)
|
||||
expect(sendButtonBeforeToken).toBeDefined()
|
||||
expect(sendButtonBeforeToken?.disabled).toBe(true)
|
||||
|
||||
const turnstileButton = root.querySelector('[data-testid="turnstile-widget"]') as HTMLButtonElement
|
||||
turnstileButton.click()
|
||||
await settle()
|
||||
|
||||
const sendButton = Array.from(root.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('发送验证码')) as HTMLButtonElement
|
||||
const sendButton = buttons.find((button) => button.disabled === false && button.type === 'button' && button !== turnstileButton) as HTMLButtonElement
|
||||
expect(sendButton).toBeDefined()
|
||||
expect(sendButton.disabled).toBe(false)
|
||||
sendButton.click()
|
||||
await settle()
|
||||
|
||||
@@ -186,6 +186,6 @@ describe('RegisterDialog Turnstile flow', () => {
|
||||
await settle()
|
||||
|
||||
expect(registerMock).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证失败')
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">提供商代理节点</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('提供商代理节点') }}</span>
|
||||
<Button
|
||||
v-if="nodeId"
|
||||
variant="ghost"
|
||||
@@ -31,7 +31,7 @@
|
||||
:disabled="saving"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
清除
|
||||
{{ legacyT('清除') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
@@ -40,7 +40,7 @@
|
||||
@update:model-value="emit('select', $event)"
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
{{ nodeId ? '当前使用提供商独立代理' : '未设置,使用系统默认网络出口' }}
|
||||
{{ legacyT(nodeId ? '当前使用提供商独立代理' : '未设置,使用系统默认网络出口') }}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -50,6 +50,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Globe } from 'lucide-vue-next'
|
||||
import { Button, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -64,4 +65,6 @@ const emit = defineEmits<{
|
||||
select: [nodeId: string]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('poolManagementState', () => {
|
||||
expect(state).toEqual({
|
||||
providerId: 'provider-c',
|
||||
search: 'stored only',
|
||||
status: 'active',
|
||||
status: 'available',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
sortBy: 'last_used_at',
|
||||
|
||||
@@ -1002,7 +1002,7 @@
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -1010,10 +1010,10 @@
|
||||
<!-- 删除端点确认弹窗 -->
|
||||
<AlertDialog
|
||||
:model-value="deleteConfirmOpen"
|
||||
title="删除端点"
|
||||
:title="legacyT('删除端点')"
|
||||
:description="deleteConfirmDescription"
|
||||
confirm-text="删除"
|
||||
cancel-text="取消"
|
||||
:confirm-text="legacyT('删除')"
|
||||
:cancel-text="legacyT('取消')"
|
||||
type="danger"
|
||||
@update:model-value="deleteConfirmOpen = $event"
|
||||
@confirm="confirmDeleteEndpoint"
|
||||
@@ -1047,6 +1047,7 @@ import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateC
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useI18n } from '@/i18n'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
@@ -1142,16 +1143,21 @@ const isEndpointFormatConversionDisabled = computed(() => {
|
||||
// 获取禁用提示
|
||||
const formatConversionDisabledTooltip = computed(() => {
|
||||
if (props.systemFormatConversionEnabled) {
|
||||
return '请先关闭系统级开关'
|
||||
return legacyT('请先关闭系统级开关')
|
||||
}
|
||||
if (props.providerFormatConversionEnabled) {
|
||||
return '请先关闭提供商级开关'
|
||||
return legacyT('请先关闭提供商级开关')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
function localizedApiError(error: unknown, fallback: string): string {
|
||||
return legacyT(parseApiError(error, fallback))
|
||||
}
|
||||
|
||||
// 规则 Select 的展开状态(与 Collapsible 分开管理)
|
||||
const ruleSelectOpen = ref<Record<string, boolean>>({})
|
||||
@@ -1488,58 +1494,70 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function formatJsonRuleFieldLabel(label: string, index: number, field: string): string {
|
||||
return locale.value === 'en-US'
|
||||
? `${label} entry ${index + 1}: ${field}`
|
||||
: `${label}第 ${index + 1} 条:${field}`
|
||||
}
|
||||
|
||||
function formatJsonRuleError(label: string, index: number, message: string): string {
|
||||
return locale.value === 'en-US'
|
||||
? `${label} entry ${index + 1}: ${legacyT(message)}`
|
||||
: `${label}第 ${index + 1} 条:${legacyT(message)}`
|
||||
}
|
||||
|
||||
function readJsonRulesArray(root: Record<string, unknown>, key: keyof EndpointRulesJsonPayload, label: string): { value: unknown[]; error: string | null } {
|
||||
const raw = root[key]
|
||||
if (raw === undefined || raw === null) return { value: [], error: null }
|
||||
if (!Array.isArray(raw)) return { value: [], error: `${label} 必须是数组或 null` }
|
||||
if (!Array.isArray(raw)) return { value: [], error: `${label} ${legacyT('必须是数组或 null')}` }
|
||||
return { value: raw, error: null }
|
||||
}
|
||||
|
||||
function validateJsonCondition(rule: Record<string, unknown>, label: string, index: number): string | null {
|
||||
const raw = rule.condition
|
||||
if (raw === undefined || raw === null) return null
|
||||
if (!isJsonObject(raw)) return `${label}第 ${index + 1} 条:condition 必须是对象`
|
||||
const shapeError = validateJsonConditionShape(raw, `${label}第 ${index + 1} 条:condition`)
|
||||
if (!isJsonObject(raw)) return formatJsonRuleError(label, index, 'condition 必须是对象')
|
||||
const shapeError = validateJsonConditionShape(raw, formatJsonRuleFieldLabel(label, index, 'condition'))
|
||||
if (shapeError) return shapeError
|
||||
const editable = conditionToEditable(raw as BodyRule['condition'])
|
||||
const err = validateEditableCondition(editable)
|
||||
return err ? `${label}第 ${index + 1} 条:${err}` : null
|
||||
return err ? formatJsonRuleError(label, index, err) : null
|
||||
}
|
||||
|
||||
function validateJsonConditionShape(condition: Record<string, unknown>, label: string): string | null {
|
||||
if (Object.prototype.hasOwnProperty.call(condition, 'all')) {
|
||||
if (!Array.isArray(condition.all)) return `${label}.all 必须是数组`
|
||||
if (!Array.isArray(condition.all)) return `${label}.all ${legacyT('必须是数组')}`
|
||||
for (let i = 0; i < condition.all.length; i++) {
|
||||
const child = condition.all[i]
|
||||
if (!isJsonObject(child)) return `${label}.all[${i}] 必须是对象`
|
||||
if (!isJsonObject(child)) return `${label}.all[${i}] ${legacyT('必须是对象')}`
|
||||
const err = validateJsonConditionShape(child, `${label}.all[${i}]`)
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(condition, 'any')) {
|
||||
if (!Array.isArray(condition.any)) return `${label}.any 必须是数组`
|
||||
if (!Array.isArray(condition.any)) return `${label}.any ${legacyT('必须是数组')}`
|
||||
for (let i = 0; i < condition.any.length; i++) {
|
||||
const child = condition.any[i]
|
||||
if (!isJsonObject(child)) return `${label}.any[${i}] 必须是对象`
|
||||
if (!isJsonObject(child)) return `${label}.any[${i}] ${legacyT('必须是对象')}`
|
||||
const err = validateJsonConditionShape(child, `${label}.any[${i}]`)
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof condition.path !== 'string') return `${label}.path 必须是字符串`
|
||||
if (typeof condition.path !== 'string') return `${label}.path ${legacyT('必须是字符串')}`
|
||||
if (typeof condition.op !== 'string' || !CONDITION_JSON_OPS.has(condition.op)) {
|
||||
return `${label}.op 无效`
|
||||
return `${label}.op ${legacyT('无效')}`
|
||||
}
|
||||
if (condition.source !== undefined && (typeof condition.source !== 'string' || !CONDITION_JSON_SOURCES.has(condition.source))) {
|
||||
return `${label}.source 无效`
|
||||
return `${label}.source ${legacyT('无效')}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function requireJsonString(rule: Record<string, unknown>, key: string, label: string, index: number): string | null {
|
||||
return typeof rule[key] === 'string' ? null : `${label}第 ${index + 1} 条:${key} 必须是字符串`
|
||||
return typeof rule[key] === 'string' ? null : formatJsonRuleError(label, index, `${key} ${legacyT('必须是字符串')}`)
|
||||
}
|
||||
|
||||
function normalizeHeaderRuleName(raw: string): string {
|
||||
@@ -1548,12 +1566,12 @@ function normalizeHeaderRuleName(raw: string): string {
|
||||
|
||||
function reservedHeaderRuleError(raw: string): string | null {
|
||||
const name = normalizeHeaderRuleName(raw)
|
||||
return name && RESERVED_HEADERS.has(name) ? `"${raw}" 是系统保留的请求头` : null
|
||||
return name && RESERVED_HEADERS.has(name) ? `"${raw}" ${legacyT('是系统保留的请求头')}` : null
|
||||
}
|
||||
|
||||
function reservedResponseHeaderRuleError(raw: string): string | null {
|
||||
const name = normalizeHeaderRuleName(raw)
|
||||
return name && RESERVED_RESPONSE_HEADERS.has(name) ? `"${raw}" 是系统保留的响应头` : null
|
||||
return name && RESERVED_RESPONSE_HEADERS.has(name) ? `"${raw}" ${legacyT('是系统保留的响应头')}` : null
|
||||
}
|
||||
|
||||
function bodyRuleTopLevelField(rawPath: string): string | null {
|
||||
@@ -1567,7 +1585,7 @@ function bodyRuleTopLevelField(rawPath: string): string | null {
|
||||
function reservedBodyRuleFieldError(rawPath: string): string | null {
|
||||
const topField = bodyRuleTopLevelField(rawPath)
|
||||
return topField && RESERVED_BODY_FIELDS.has(topField)
|
||||
? `"${topField}" 是系统保留的顶层字段`
|
||||
? `"${topField}" ${legacyT('是系统保留的顶层字段')}`
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -1577,13 +1595,13 @@ function validateHeaderRuleJson(
|
||||
index: number,
|
||||
reservedNameError: (raw: string) => string | null = reservedHeaderRuleError
|
||||
): string | null {
|
||||
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
|
||||
if (!isJsonObject(rule)) return formatJsonRuleError(label, index, '必须是对象')
|
||||
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
|
||||
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
|
||||
return formatJsonRuleError(label, index, 'enabled 必须是布尔值')
|
||||
}
|
||||
const action = rule.action
|
||||
if (action !== 'set' && action !== 'drop' && action !== 'rename') {
|
||||
return `${label}第 ${index + 1} 条:action 必须是 set/drop/rename`
|
||||
return formatJsonRuleError(label, index, 'action 必须是 set/drop/rename')
|
||||
}
|
||||
if (action === 'set') {
|
||||
return requireJsonString(rule, 'key', label, index)
|
||||
@@ -1604,19 +1622,19 @@ function validateHeaderRuleJson(
|
||||
}
|
||||
|
||||
function validateBodyRuleJson(rule: unknown, label: string, index: number): string | null {
|
||||
if (!isJsonObject(rule)) return `${label}第 ${index + 1} 条必须是对象`
|
||||
if (!isJsonObject(rule)) return formatJsonRuleError(label, index, '必须是对象')
|
||||
if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') {
|
||||
return `${label}第 ${index + 1} 条:enabled 必须是布尔值`
|
||||
return formatJsonRuleError(label, index, 'enabled 必须是布尔值')
|
||||
}
|
||||
const action = typeof rule.action === 'string' ? rule.action : ''
|
||||
if (!BODY_RULE_JSON_ACTIONS.has(action)) {
|
||||
return `${label}第 ${index + 1} 条:action 无效`
|
||||
return formatJsonRuleError(label, index, 'action 无效')
|
||||
}
|
||||
|
||||
if (action === 'set' || action === 'append') {
|
||||
return requireJsonString(rule, 'path', label, index)
|
||||
|| reservedBodyRuleFieldError(rule.path as string)
|
||||
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : `${label}第 ${index + 1} 条:value 不能为空`)
|
||||
|| (Object.prototype.hasOwnProperty.call(rule, 'value') ? null : formatJsonRuleError(label, index, 'value 不能为空'))
|
||||
|| validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'drop') {
|
||||
@@ -1634,20 +1652,20 @@ function validateBodyRuleJson(rule: unknown, label: string, index: number): stri
|
||||
if (action === 'insert') {
|
||||
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
|
||||
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
|
||||
if (!Number.isInteger(rule.index)) return `${label}第 ${index + 1} 条:index 必须是整数`
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return `${label}第 ${index + 1} 条:value 不能为空`
|
||||
if (!Number.isInteger(rule.index)) return formatJsonRuleError(label, index, 'index 必须是整数')
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'value')) return formatJsonRuleError(label, index, 'value 不能为空')
|
||||
return validateJsonCondition(rule, label, index)
|
||||
}
|
||||
if (action === 'regex_replace') {
|
||||
if (requireJsonString(rule, 'path', label, index)) return requireJsonString(rule, 'path', label, index)
|
||||
if (reservedBodyRuleFieldError(rule.path as string)) return reservedBodyRuleFieldError(rule.path as string)
|
||||
if (requireJsonString(rule, 'pattern', label, index)) return requireJsonString(rule, 'pattern', label, index)
|
||||
if (typeof rule.replacement !== 'string') return `${label}第 ${index + 1} 条:replacement 必须是字符串`
|
||||
if (rule.flags !== undefined && typeof rule.flags !== 'string') return `${label}第 ${index + 1} 条:flags 必须是字符串`
|
||||
if (rule.count !== undefined && !Number.isInteger(rule.count)) return `${label}第 ${index + 1} 条:count 必须是整数`
|
||||
if (typeof rule.replacement !== 'string') return formatJsonRuleError(label, index, 'replacement 必须是字符串')
|
||||
if (rule.flags !== undefined && typeof rule.flags !== 'string') return formatJsonRuleError(label, index, 'flags 必须是字符串')
|
||||
if (rule.count !== undefined && !Number.isInteger(rule.count)) return formatJsonRuleError(label, index, 'count 必须是整数')
|
||||
return validateJsonCondition(rule, label, index)
|
||||
}
|
||||
return `${label}第 ${index + 1} 条:action 无效`
|
||||
return formatJsonRuleError(label, index, 'action 无效')
|
||||
}
|
||||
|
||||
function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonPayload | null; error: string | null } {
|
||||
@@ -1660,9 +1678,9 @@ function parseEndpointRulesJsonDraft(draft: string): { value: EndpointRulesJsonP
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error: unknown) {
|
||||
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
|
||||
return { value: null, error: error instanceof Error ? error.message : legacyT('JSON 格式无效') }
|
||||
}
|
||||
if (!isJsonObject(parsed)) return { value: null, error: '规则 JSON 必须是对象' }
|
||||
if (!isJsonObject(parsed)) return { value: null, error: legacyT('规则 JSON 必须是对象') }
|
||||
|
||||
const header = readJsonRulesArray(parsed, 'header_rules', 'header_rules')
|
||||
if (header.error) return { value: null, error: header.error }
|
||||
@@ -1707,14 +1725,14 @@ function applyEndpointRulesJsonDraft(
|
||||
const parsed = parseEndpointRulesJsonDraft(endpointRulesJsonDraft.value[endpointId] ?? '')
|
||||
if (!parsed.value) {
|
||||
endpointRulesJsonError.value[endpointId] = parsed.error
|
||||
if (notifyError) showError(parsed.error || '规则 JSON 无效')
|
||||
if (notifyError) showError(legacyT(parsed.error || '规则 JSON 无效'))
|
||||
return false
|
||||
}
|
||||
|
||||
const state = ensureEndpointEditState(endpointId)
|
||||
if (!state) {
|
||||
endpointRulesJsonError.value[endpointId] = '端点编辑状态不可用'
|
||||
if (notifyError) showError('端点编辑状态不可用')
|
||||
endpointRulesJsonError.value[endpointId] = legacyT('端点编辑状态不可用')
|
||||
if (notifyError) showError(legacyT('端点编辑状态不可用'))
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1734,14 +1752,14 @@ function applyEndpointRulesJsonDraft(
|
||||
|| getBodyValidationErrorForEndpoint(endpointId)
|
||||
if (validationError) {
|
||||
endpointRulesJsonError.value[endpointId] = validationError
|
||||
if (notifyError) showError(validationError)
|
||||
if (notifyError) showError(legacyT(validationError))
|
||||
return false
|
||||
}
|
||||
|
||||
endpointRulesJsonDraft.value[endpointId] = stringifyEndpointRulesJsonPayload(parsed.value)
|
||||
endpointRulesJsonError.value[endpointId] = null
|
||||
endpointRulesJsonDirty.value[endpointId] = false
|
||||
if (options.notify !== false) success('JSON 规则已应用')
|
||||
if (options.notify !== false) success(legacyT('JSON 规则已应用'))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1878,7 +1896,9 @@ const availableFormats = computed(() => {
|
||||
const deleteConfirmDescription = computed(() => {
|
||||
if (!endpointToDelete.value) return ''
|
||||
const formatLabel = formatApiFormat(endpointToDelete.value.api_format)
|
||||
return `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
|
||||
return locale.value === 'en-US'
|
||||
? `Delete the ${formatLabel} endpoint? Linked keys will no longer support this API format.`
|
||||
: `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
|
||||
})
|
||||
|
||||
function defaultBodyRulesCacheKey(apiFormat: string): string {
|
||||
@@ -1985,14 +2005,14 @@ function endpointProxyNodeId(endpoint: ProviderEndpoint): string {
|
||||
|
||||
function getEndpointProxyNodeName(endpoint: ProviderEndpoint): string {
|
||||
const nodeId = endpointProxyNodeId(endpoint)
|
||||
if (!nodeId) return '未知节点'
|
||||
if (!nodeId) return legacyT('未知节点')
|
||||
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
|
||||
return node ? node.name : `${nodeId.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
function getEndpointProxyTitle(endpoint: ProviderEndpoint): string {
|
||||
const nodeId = endpointProxyNodeId(endpoint)
|
||||
return nodeId ? `端点代理: ${getEndpointProxyNodeName(endpoint)}` : '设置端点代理节点'
|
||||
return nodeId ? `${legacyT('端点代理')}: ${getEndpointProxyNodeName(endpoint)}` : legacyT('设置端点代理节点')
|
||||
}
|
||||
|
||||
function handleEndpointProxyPopoverToggle(endpointId: string, open: boolean) {
|
||||
@@ -2019,10 +2039,10 @@ async function setEndpointProxy(endpoint: ProviderEndpoint, nodeId: string) {
|
||||
})
|
||||
replaceLocalEndpoint(updated)
|
||||
endpointProxyPopoverOpen.value[endpoint.id] = false
|
||||
success('端点代理已更新')
|
||||
success(legacyT('端点代理已更新'))
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '更新代理失败'), '错误')
|
||||
showError(localizedApiError(error, '更新代理失败'), legacyT('错误'))
|
||||
} finally {
|
||||
savingEndpointId.value = null
|
||||
}
|
||||
@@ -2034,10 +2054,10 @@ async function clearEndpointProxy(endpoint: ProviderEndpoint) {
|
||||
const updated = await updateEndpoint(endpoint.id, { proxy: null })
|
||||
replaceLocalEndpoint(updated)
|
||||
endpointProxyPopoverOpen.value[endpoint.id] = false
|
||||
success('端点代理已清除')
|
||||
success(legacyT('端点代理已清除'))
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '清除代理失败'), '错误')
|
||||
showError(localizedApiError(error, '清除代理失败'), legacyT('错误'))
|
||||
} finally {
|
||||
savingEndpointId.value = null
|
||||
}
|
||||
@@ -2399,7 +2419,7 @@ function validateRuleKeyForEndpoint(endpointId: string, key: string, index: numb
|
||||
)
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '请求头名称重复'
|
||||
return legacyT('请求头名称重复')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2423,7 +2443,7 @@ function validateRenameFromForEndpoint(endpointId: string, from: string, index:
|
||||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '该请求头已被其他规则处理'
|
||||
return legacyT('该请求头已被其他规则处理')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2446,7 +2466,7 @@ function validateRenameToForEndpoint(endpointId: string, to: string, index: numb
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '请求头名称重复'
|
||||
return legacyT('请求头名称重复')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2575,7 +2595,7 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
||||
const dotPart = raw.includes('[') ? raw.slice(0, raw.indexOf('[')) : raw
|
||||
const parts = dotPart ? parseBodyRulePathParts(dotPart) : [raw.split('[')[0] || raw]
|
||||
if (!parts) {
|
||||
return '路径格式无效'
|
||||
return legacyT('路径格式无效')
|
||||
}
|
||||
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
@@ -2594,7 +2614,7 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
||||
)
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '字段路径重复'
|
||||
return legacyT('字段路径重复')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2607,7 +2627,7 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
||||
|
||||
const parts = parseBodyRulePathParts(raw)
|
||||
if (!parts) {
|
||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||
return legacyT('路径格式无效(不允许 .a / a. / a..b)')
|
||||
}
|
||||
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
@@ -2625,7 +2645,7 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
||||
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '该路径已被其他规则处理'
|
||||
return legacyT('该路径已被其他规则处理')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2638,7 +2658,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
||||
|
||||
const parts = parseBodyRulePathParts(raw)
|
||||
if (!parts) {
|
||||
return '路径格式无效(不允许 .a / a. / a..b)'
|
||||
return legacyT('路径格式无效(不允许 .a / a. / a..b)')
|
||||
}
|
||||
|
||||
const reservedErr = reservedBodyRuleFieldError(raw)
|
||||
@@ -2655,7 +2675,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return '字段路径重复'
|
||||
return legacyT('字段路径重复')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -2665,12 +2685,12 @@ function validateBodySetValue(rule: EditableBodyRule): string | null {
|
||||
if (rule.action !== 'set' && rule.action !== 'append' && rule.action !== 'insert') return null
|
||||
|
||||
const raw = rule.value.trim()
|
||||
if (!raw) return '值不能为空'
|
||||
if (!raw) return legacyT('值不能为空')
|
||||
try {
|
||||
JSON.parse(prepareValueForJsonParse(raw))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return `JSON 格式错误:${msg}`
|
||||
return locale.value === 'en-US' ? `JSON format error: ${msg}` : `JSON 格式错误:${msg}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2712,12 +2732,12 @@ function getRegexPatternValidation(rule: EditableBodyRule): boolean | null {
|
||||
// 获取正则验证提示
|
||||
function getRegexPatternValidationTip(rule: EditableBodyRule): string {
|
||||
const validation = getRegexPatternValidation(rule)
|
||||
if (validation === null) return '输入正则表达式'
|
||||
if (validation === true) return '有效的正则表达式'
|
||||
if (validation === null) return legacyT('输入正则表达式')
|
||||
if (validation === true) return legacyT('有效的正则表达式')
|
||||
try {
|
||||
new RegExp(rule.pattern.trim())
|
||||
// 正则有效但 flags 无效
|
||||
return '无效的 flags(仅允许 i/m/s)'
|
||||
return legacyT('无效的 flags(仅允许 i/m/s)')
|
||||
} catch (err: unknown) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
@@ -2726,11 +2746,21 @@ function getRegexPatternValidationTip(rule: EditableBodyRule): string {
|
||||
// 获取验证提示
|
||||
function getBodySetValueValidationTip(rule: EditableBodyRule): string {
|
||||
const validation = getBodySetValueValidation(rule)
|
||||
if (validation === null) return '点击验证 JSON'
|
||||
if (validation === null) return legacyT('点击验证 JSON')
|
||||
if (validation === true) {
|
||||
const parsed = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim())))
|
||||
const type = Array.isArray(parsed) ? '数组' : typeof parsed === 'object' && parsed !== null ? '对象' : typeof parsed === 'string' ? '字符串' : typeof parsed === 'number' ? '数字' : typeof parsed === 'boolean' ? '布尔' : 'null'
|
||||
return `有效的 JSON (${type})`
|
||||
const type = Array.isArray(parsed)
|
||||
? legacyT('数组')
|
||||
: typeof parsed === 'object' && parsed !== null
|
||||
? legacyT('对象')
|
||||
: typeof parsed === 'string'
|
||||
? legacyT('字符串')
|
||||
: typeof parsed === 'number'
|
||||
? legacyT('数字')
|
||||
: typeof parsed === 'boolean'
|
||||
? legacyT('布尔')
|
||||
: 'null'
|
||||
return `${legacyT('有效的 JSON')} (${type})`
|
||||
}
|
||||
try {
|
||||
JSON.parse(prepareValueForJsonParse(rule.value.trim()))
|
||||
@@ -2793,43 +2823,43 @@ function getTotalRulesCount(endpoint: ProviderEndpoint): number {
|
||||
// 格式化请求头规则的显示标签
|
||||
function _formatHeaderRuleLabel(rule: EditableRule): string {
|
||||
if (rule.action === 'set') {
|
||||
if (!rule.key) return '(未设置)'
|
||||
if (!rule.key) return legacyT('(未设置)')
|
||||
return `${rule.key}=${rule.value || '...'}`
|
||||
} else if (rule.action === 'drop') {
|
||||
if (!rule.key) return '(未设置)'
|
||||
if (!rule.key) return legacyT('(未设置)')
|
||||
return `-${rule.key}`
|
||||
} else if (rule.action === 'rename') {
|
||||
if (!rule.from || !rule.to) return '(未设置)'
|
||||
if (!rule.from || !rule.to) return legacyT('(未设置)')
|
||||
return `${rule.from}→${rule.to}`
|
||||
}
|
||||
return '(未知)'
|
||||
return legacyT('(未知)')
|
||||
}
|
||||
|
||||
// 格式化请求体规则的显示标签
|
||||
function _formatBodyRuleLabel(rule: EditableBodyRule): string {
|
||||
if (rule.action === 'set') {
|
||||
if (!rule.path) return '(未设置)'
|
||||
if (!rule.path) return legacyT('(未设置)')
|
||||
return `${rule.path}=${rule.value || '...'}`
|
||||
} else if (rule.action === 'drop') {
|
||||
if (!rule.path) return '(未设置)'
|
||||
if (!rule.path) return legacyT('(未设置)')
|
||||
return `-${rule.path}`
|
||||
} else if (rule.action === 'rename') {
|
||||
if (!rule.from || !rule.to) return '(未设置)'
|
||||
if (!rule.from || !rule.to) return legacyT('(未设置)')
|
||||
return `${rule.from}→${rule.to}`
|
||||
} else if (rule.action === 'append') {
|
||||
if (!rule.path) return '(未设置)'
|
||||
if (!rule.path) return legacyT('(未设置)')
|
||||
return `${rule.path}[]+=${rule.value || '...'}`
|
||||
} else if (rule.action === 'insert') {
|
||||
if (!rule.path) return '(未设置)'
|
||||
const idx = rule.index?.trim() || '末尾'
|
||||
if (!rule.path) return legacyT('(未设置)')
|
||||
const idx = rule.index?.trim() || legacyT('末尾')
|
||||
return `${rule.path}[${idx}]+=${rule.value || '...'}`
|
||||
} else if (rule.action === 'regex_replace') {
|
||||
if (!rule.path || !rule.pattern) return '(未设置)'
|
||||
if (!rule.path || !rule.pattern) return legacyT('(未设置)')
|
||||
const flags = rule.flags.trim()
|
||||
const count = rule.count.trim()
|
||||
return `${rule.path}: s/${rule.pattern}/${rule.replacement || ''}/${flags}${count ? ` ×${count}` : ''}`
|
||||
}
|
||||
return '(未知)'
|
||||
return legacyT('(未知)')
|
||||
}
|
||||
|
||||
// 检查端点请求体规则是否有修改
|
||||
@@ -2921,7 +2951,7 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]
|
||||
if (!rule.enabled) continue
|
||||
const prefix = `第 ${i + 1} 条请求体规则:`
|
||||
const prefix = locale.value === 'en-US' ? `Body rule ${i + 1}: ` : `第 ${i + 1} 条请求体规则:`
|
||||
|
||||
if (rule.action === 'set' || rule.action === 'drop') {
|
||||
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||
@@ -2944,29 +2974,32 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
||||
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||
if (pathErr) return `${prefix}${pathErr}`
|
||||
const indexStr = rule.index.trim()
|
||||
if (!indexStr) return `${prefix}插入位置不能为空`
|
||||
if (!isStrictIntegerString(indexStr)) return `${prefix}位置必须为整数`
|
||||
if (!indexStr) return `${prefix}${legacyT('插入位置不能为空')}`
|
||||
if (!isStrictIntegerString(indexStr)) return `${prefix}${legacyT('位置必须为整数')}`
|
||||
const valueErr = validateBodySetValue(rule)
|
||||
if (valueErr) return `${prefix}${valueErr}`
|
||||
} else if (rule.action === 'regex_replace') {
|
||||
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||
if (pathErr) return `${prefix}${pathErr}`
|
||||
if (!rule.pattern.trim()) return `${prefix}正则表达式不能为空`
|
||||
if (!rule.pattern.trim()) return `${prefix}${legacyT('正则表达式不能为空')}`
|
||||
try {
|
||||
new RegExp(rule.pattern.trim())
|
||||
} catch (err: unknown) {
|
||||
return `${prefix}正则表达式无效:${err instanceof Error ? err.message : String(err)}`
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return locale.value === 'en-US'
|
||||
? `${prefix}Invalid regular expression: ${message}`
|
||||
: `${prefix}正则表达式无效:${message}`
|
||||
}
|
||||
const flags = rule.flags.trim()
|
||||
if (flags) {
|
||||
const validFlags = new Set(['i', 'm', 's'])
|
||||
for (const f of flags) {
|
||||
if (!validFlags.has(f)) return `${prefix}flags 仅允许 i/m/s,非法字符: ${f}`
|
||||
if (!validFlags.has(f)) return `${prefix}${legacyT('flags 仅允许 i/m/s,非法字符')}: ${f}`
|
||||
}
|
||||
}
|
||||
const count = rule.count.trim()
|
||||
if (count) {
|
||||
if (!isStrictNonNegativeIntegerString(count)) return `${prefix}替换次数必须是大于等于 0 的整数`
|
||||
if (!isStrictNonNegativeIntegerString(count)) return `${prefix}${legacyT('替换次数必须是大于等于 0 的整数')}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3058,7 +3091,7 @@ async function handleResetBodyRulesToDefault(endpoint: ProviderEndpoint) {
|
||||
try {
|
||||
const defaultRules = await loadDefaultBodyRulesForFormat(endpoint.api_format, true)
|
||||
if (!defaultRules.length) {
|
||||
showError('该端点没有默认请求体规则')
|
||||
showError(legacyT('该端点没有默认请求体规则'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3079,9 +3112,9 @@ async function handleResetBodyRulesToDefault(endpoint: ProviderEndpoint) {
|
||||
if (isEndpointRulesJsonMode(endpoint.id)) {
|
||||
refreshEndpointRulesJsonDraft(endpoint.id)
|
||||
}
|
||||
success('已重置请求体为默认规则,请点击保存生效')
|
||||
success(legacyT('已重置请求体为默认规则,请点击保存生效'))
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '重置失败'), '错误')
|
||||
showError(localizedApiError(error, '重置失败'), legacyT('错误'))
|
||||
} finally {
|
||||
resettingDefaultRulesEndpointId.value = null
|
||||
}
|
||||
@@ -3121,7 +3154,7 @@ function getHeaderValidationErrorForEndpoint(endpointId: string): string | null
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]
|
||||
if (!rule.enabled) continue
|
||||
const prefix = `第 ${i + 1} 条请求头规则:`
|
||||
const prefix = locale.value === 'en-US' ? `Header rule ${i + 1}: ` : `第 ${i + 1} 条请求头规则:`
|
||||
if (rule.action === 'set' || rule.action === 'drop') {
|
||||
const err = validateRuleKeyForEndpoint(endpointId, rule.key, i)
|
||||
if (err) return `${prefix}${err}`
|
||||
@@ -3156,7 +3189,7 @@ function validateResponseHeaderNameForEndpoint(endpointId: string, name: string,
|
||||
)
|
||||
)
|
||||
if (duplicate >= 0) {
|
||||
return field === 'from' ? '该响应头已被其他规则处理' : '响应头名称重复'
|
||||
return legacyT(field === 'from' ? '该响应头已被其他规则处理' : '响应头名称重复')
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -3167,7 +3200,7 @@ function getResponseHeaderValidationErrorForEndpoint(endpointId: string): string
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]
|
||||
if (!rule.enabled) continue
|
||||
const prefix = `第 ${i + 1} 条响应头规则:`
|
||||
const prefix = locale.value === 'en-US' ? `Response header rule ${i + 1}: ` : `第 ${i + 1} 条响应头规则:`
|
||||
if (rule.action === 'set' || rule.action === 'drop') {
|
||||
const err = validateResponseHeaderNameForEndpoint(endpointId, rule.key, i, 'key')
|
||||
if (err) return `${prefix}${err}`
|
||||
@@ -3274,20 +3307,20 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
||||
// 检查规则是否有验证错误
|
||||
const headerErr = getHeaderValidationErrorForEndpoint(endpoint.id)
|
||||
if (headerErr) {
|
||||
showError(headerErr)
|
||||
showError(legacyT(headerErr))
|
||||
return
|
||||
}
|
||||
|
||||
const responseHeaderErr = getResponseHeaderValidationErrorForEndpoint(endpoint.id)
|
||||
if (responseHeaderErr) {
|
||||
showError(responseHeaderErr)
|
||||
showError(legacyT(responseHeaderErr))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查请求体规则是否有验证错误
|
||||
const bodyErr = getBodyValidationErrorForEndpoint(endpoint.id)
|
||||
if (bodyErr) {
|
||||
showError(bodyErr)
|
||||
showError(legacyT(bodyErr))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3315,10 +3348,10 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
||||
if (Object.keys(payload).length === 0) return
|
||||
|
||||
await updateEndpoint(endpoint.id, payload)
|
||||
success('端点已更新')
|
||||
success(legacyT('端点已更新'))
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '更新失败'), '错误')
|
||||
showError(localizedApiError(error, '更新失败'), legacyT('错误'))
|
||||
} finally {
|
||||
savingEndpointId.value = null
|
||||
}
|
||||
@@ -3334,10 +3367,10 @@ async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
|
||||
await updateEndpoint(endpoint.id, {
|
||||
format_acceptance_config: newEnabled ? { enabled: true } : null,
|
||||
})
|
||||
success(newEnabled ? '已启用格式转换' : '已关闭格式转换')
|
||||
success(legacyT(newEnabled ? '已启用格式转换' : '已关闭格式转换'))
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '操作失败'), '错误')
|
||||
showError(localizedApiError(error, '操作失败'), legacyT('错误'))
|
||||
} finally {
|
||||
togglingFormatEndpointId.value = null
|
||||
}
|
||||
@@ -3369,11 +3402,11 @@ function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||
|
||||
// 获取上游流式按钮的提示文字
|
||||
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return '固定流式(Codex OpenAI Responses,已锁定)'
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return legacyT('固定流式(Codex OpenAI Responses,已锁定)')
|
||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
|
||||
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
|
||||
return '跟随请求(点击切换为固定流式)'
|
||||
if (policy === 'force_stream') return legacyT('固定流式(点击切换为固定非流)')
|
||||
if (policy === 'force_non_stream') return legacyT('固定非流(点击切换为跟随请求)')
|
||||
return legacyT('跟随请求(点击切换为固定流式)')
|
||||
}
|
||||
|
||||
// 循环切换上游流式策略并直接保存
|
||||
@@ -3387,13 +3420,13 @@ async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||
// 循环:auto -> force_stream -> force_non_stream -> auto
|
||||
if (currentPolicy === 'auto') {
|
||||
nextPolicy = 'force_stream'
|
||||
nextLabel = '固定流式'
|
||||
nextLabel = legacyT('固定流式')
|
||||
} else if (currentPolicy === 'force_stream') {
|
||||
nextPolicy = 'force_non_stream'
|
||||
nextLabel = '固定非流'
|
||||
nextLabel = legacyT('固定非流')
|
||||
} else {
|
||||
nextPolicy = 'auto'
|
||||
nextLabel = '跟随请求'
|
||||
nextLabel = legacyT('跟随请求')
|
||||
}
|
||||
|
||||
savingEndpointId.value = endpoint.id
|
||||
@@ -3417,10 +3450,10 @@ async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||
endpointEditStates.value[endpoint.id].upstreamStreamPolicy = nextPolicy
|
||||
}
|
||||
|
||||
success(`已切换为${nextLabel}`)
|
||||
success(locale.value === 'en-US' ? `Switched to ${nextLabel}` : `已切换为${nextLabel}`)
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '操作失败'), '错误')
|
||||
showError(localizedApiError(error, '操作失败'), legacyT('错误'))
|
||||
} finally {
|
||||
savingEndpointId.value = null
|
||||
}
|
||||
@@ -3433,7 +3466,7 @@ async function handleAddEndpoint() {
|
||||
// 如果没有输入 base_url,使用按格式规范化后的提供商 website 作为默认值。
|
||||
const baseUrl = getNewEndpointBaseUrl()
|
||||
if (!baseUrl) {
|
||||
showError('请输入 Base URL')
|
||||
showError(legacyT('请输入 Base URL'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3446,12 +3479,14 @@ async function handleAddEndpoint() {
|
||||
custom_path: newEndpoint.value.custom_path || undefined,
|
||||
is_active: true,
|
||||
})
|
||||
success(`已添加 ${formatApiFormat(newEndpoint.value.api_format)} 端点`)
|
||||
success(locale.value === 'en-US'
|
||||
? `Added ${formatApiFormat(newEndpoint.value.api_format)} endpoint`
|
||||
: `已添加 ${formatApiFormat(newEndpoint.value.api_format)} 端点`)
|
||||
// 重置表单,保留 URL
|
||||
newEndpoint.value = { api_format: '', base_url: baseUrl, custom_path: '' }
|
||||
emit('endpointCreated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '添加失败'), '错误')
|
||||
showError(localizedApiError(error, '添加失败'), legacyT('错误'))
|
||||
} finally {
|
||||
addingEndpoint.value = false
|
||||
}
|
||||
@@ -3463,10 +3498,10 @@ async function handleToggleEndpoint(endpoint: ProviderEndpoint) {
|
||||
try {
|
||||
const newStatus = !endpoint.is_active
|
||||
await updateEndpoint(endpoint.id, { is_active: newStatus })
|
||||
success(newStatus ? '端点已启用' : '端点已停用')
|
||||
success(legacyT(newStatus ? '端点已启用' : '端点已停用'))
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '操作失败'), '错误')
|
||||
showError(localizedApiError(error, '操作失败'), legacyT('错误'))
|
||||
} finally {
|
||||
togglingEndpointId.value = null
|
||||
}
|
||||
@@ -3488,10 +3523,12 @@ async function confirmDeleteEndpoint() {
|
||||
|
||||
try {
|
||||
await deleteEndpoint(endpoint.id)
|
||||
success(`已删除 ${formatApiFormat(endpoint.api_format)} 端点`)
|
||||
success(locale.value === 'en-US'
|
||||
? `Deleted ${formatApiFormat(endpoint.api_format)} endpoint`
|
||||
: `已删除 ${formatApiFormat(endpoint.api_format)} 端点`)
|
||||
emit('endpointUpdated')
|
||||
} catch (error: unknown) {
|
||||
showError(parseApiError(error, '删除失败'), '错误')
|
||||
showError(localizedApiError(error, '删除失败'), legacyT('错误'))
|
||||
} finally {
|
||||
deletingEndpointId.value = null
|
||||
endpointToDelete.value = null
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
:title="isEditMode ? '编辑密钥' : '添加密钥'"
|
||||
:description="isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥'"
|
||||
:title="legacyT(isEditMode ? '编辑密钥' : '添加密钥')"
|
||||
:description="legacyT(isEditMode ? '修改 API 密钥配置' : '为提供商添加新的 API 密钥')"
|
||||
:icon="isEditMode ? SquarePen : Key"
|
||||
size="xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -15,13 +15,13 @@
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label :for="keyNameInputId">密钥名称 *</Label>
|
||||
<Label :for="keyNameInputId">{{ legacyT('密钥名称 *') }}</Label>
|
||||
<Input
|
||||
:id="keyNameInputId"
|
||||
v-model="form.name"
|
||||
:name="keyNameFieldName"
|
||||
required
|
||||
placeholder="例如:主 Key、备用 Key 1"
|
||||
:placeholder="legacyT('例如:主 Key、备用 Key 1')"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
@@ -33,10 +33,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showAuthTypeSelector">
|
||||
<Label :for="authTypeSelectId">认证类型</Label>
|
||||
<Label :for="authTypeSelectId">{{ legacyT('认证类型') }}</Label>
|
||||
<Select v-model="form.auth_type">
|
||||
<SelectTrigger :id="authTypeSelectId">
|
||||
<SelectValue placeholder="选择认证类型" />
|
||||
<SelectValue :placeholder="legacyT('选择认证类型')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -61,9 +61,9 @@
|
||||
:reset-key="formNonce"
|
||||
accept=".json,.txt,application/json,text/plain"
|
||||
:multiple="false"
|
||||
drop-title="拖入 Service Account JSON 或点击选择"
|
||||
drop-hint="支持 .json / .txt,单文件导入"
|
||||
:manual-placeholder="editingKey ? '留空表示不修改,或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
|
||||
:drop-title="legacyT('拖入 Service Account JSON 或点击选择')"
|
||||
:drop-hint="legacyT('支持 .json / .txt,单文件导入')"
|
||||
:manual-placeholder="legacyT(editingKey ? '留空表示不修改,或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON')"
|
||||
:manual-description="serviceAccountDescription"
|
||||
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
|
||||
@error="handleServiceAccountImportError"
|
||||
@@ -83,25 +83,25 @@
|
||||
v-if="editingKey && isRawSecretAuthType(form.auth_type)"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
留空表示不修改
|
||||
{{ legacyT('留空表示不修改') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div>
|
||||
<Label for="note">备注</Label>
|
||||
<Label for="note">{{ legacyT('备注') }}</Label>
|
||||
<Input
|
||||
id="note"
|
||||
v-model="form.note"
|
||||
placeholder="可选的备注信息"
|
||||
:placeholder="legacyT('可选的备注信息')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API 格式 & 认证方式 -->
|
||||
<div v-if="visibleApiFormats.length > 0">
|
||||
<div class="flex items-center gap-1 mb-1.5">
|
||||
<Label>支持的 API 格式 *</Label>
|
||||
<Label>{{ legacyT('支持的 API 格式 *') }}</Label>
|
||||
<span
|
||||
class="relative inline-flex"
|
||||
@mouseenter="apiFormatHelpHovered = true"
|
||||
@@ -110,8 +110,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title="API 格式说明"
|
||||
aria-label="API 格式说明"
|
||||
:title="legacyT('API 格式说明')"
|
||||
:aria-label="legacyT('API 格式说明')"
|
||||
:aria-expanded="apiFormatHelpVisible"
|
||||
@click.stop="toggleApiFormatHelp"
|
||||
@focus="apiFormatHelpHovered = true"
|
||||
@@ -125,7 +125,7 @@
|
||||
role="tooltip"
|
||||
class="absolute left-0 top-full z-[100] mt-1 w-80 rounded-md border bg-popover px-3 py-2 text-xs font-normal normal-case leading-5 tracking-normal text-popover-foreground shadow-md"
|
||||
>
|
||||
选择此密钥支持的 API 格式及对应认证方式。OpenAI 格式固定使用 Bearer Token;Claude / Gemini 格式可选 API Key 或 Bearer Token(如 Claude Code 应使用 Bearer Token)。
|
||||
{{ legacyT('选择此密钥支持的 API 格式及对应认证方式。OpenAI 格式固定使用 Bearer Token;Claude / Gemini 格式可选 API Key 或 Bearer Token(如 Claude Code 应使用 Bearer Token)。') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@
|
||||
<div
|
||||
v-if="canToggleAuthChannelMismatch(format)"
|
||||
class="flex items-center gap-1"
|
||||
title="允许客户端认证方式不一致时使用"
|
||||
:title="legacyT('允许客户端认证方式不一致时使用')"
|
||||
@click.stop
|
||||
>
|
||||
<Switch
|
||||
@@ -203,7 +203,7 @@
|
||||
<Label
|
||||
for="internal_priority"
|
||||
class="text-xs"
|
||||
>优先级</Label>
|
||||
>{{ legacyT('优先级') }}</Label>
|
||||
<Input
|
||||
id="internal_priority"
|
||||
v-model.number="form.internal_priority"
|
||||
@@ -212,51 +212,51 @@
|
||||
class="h-8"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
越小越优先
|
||||
{{ legacyT('越小越优先') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="rpm_limit"
|
||||
class="text-xs"
|
||||
>RPM 限制</Label>
|
||||
>{{ legacyT('RPM 限制') }}</Label>
|
||||
<Input
|
||||
id="rpm_limit"
|
||||
:model-value="form.rpm_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="自适应"
|
||||
:placeholder="legacyT('自适应')"
|
||||
class="h-8"
|
||||
@update:model-value="(v) => form.rpm_limit = parseNullableNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
留空自适应
|
||||
{{ legacyT('留空自适应') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="concurrent_limit"
|
||||
class="text-xs"
|
||||
>并发请求上限</Label>
|
||||
>{{ legacyT('并发请求上限') }}</Label>
|
||||
<Input
|
||||
id="concurrent_limit"
|
||||
:model-value="form.concurrent_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="不限制"
|
||||
:placeholder="legacyT('不限制')"
|
||||
class="h-8"
|
||||
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
留空或 0 表示不限制
|
||||
{{ legacyT('留空或 0 表示不限制') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="cache_ttl_minutes"
|
||||
class="text-xs"
|
||||
>缓存 TTL</Label>
|
||||
>{{ legacyT('缓存 TTL') }}</Label>
|
||||
<Input
|
||||
id="cache_ttl_minutes"
|
||||
:model-value="form.cache_ttl_minutes ?? ''"
|
||||
@@ -267,14 +267,14 @@
|
||||
@update:model-value="(v) => form.cache_ttl_minutes = parseNumberInput(v, { min: 0, max: 60 }) ?? 5"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
分钟,0禁用
|
||||
{{ legacyT('分钟,0禁用') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="max_probe_interval_minutes"
|
||||
class="text-xs"
|
||||
>熔断探测</Label>
|
||||
>{{ legacyT('熔断探测') }}</Label>
|
||||
<Input
|
||||
id="max_probe_interval_minutes"
|
||||
:model-value="form.max_probe_interval_minutes ?? ''"
|
||||
@@ -286,7 +286,7 @@
|
||||
@update:model-value="(v) => form.max_probe_interval_minutes = parseNumberInput(v, { min: 0, max: 32 }) ?? 32"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
分钟,0-32
|
||||
{{ legacyT('分钟,0-32') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,9 +295,9 @@
|
||||
<div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label class="text-sm font-medium">自动获取上游可用模型</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('自动获取上游可用模型') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
定时更新上游模型, 配合模型映射使用
|
||||
{{ legacyT('定时更新上游模型, 配合模型映射使用') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showAutoFetchWarning"
|
||||
@@ -316,15 +316,15 @@
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label class="text-xs">包含规则</Label>
|
||||
<Label class="text-xs">{{ legacyT('包含规则') }}</Label>
|
||||
<Input
|
||||
v-model="form.model_include_patterns_text"
|
||||
placeholder="gpt-*, claude-*, 留空包含全部"
|
||||
:placeholder="legacyT('gpt-*, claude-*, 留空包含全部')"
|
||||
class="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs">排除规则</Label>
|
||||
<Label class="text-xs">{{ legacyT('排除规则') }}</Label>
|
||||
<Input
|
||||
v-model="form.model_exclude_patterns_text"
|
||||
placeholder="*-preview, *-beta"
|
||||
@@ -333,7 +333,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
逗号分隔,支持 * ? 通配符,不区分大小写
|
||||
{{ legacyT('逗号分隔,支持 * ? 通配符,不区分大小写') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,13 +344,13 @@
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? (isEditMode ? '保存中...' : '添加中...') : (isEditMode ? '保存' : '添加') }}
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -373,6 +373,7 @@ import {
|
||||
import { Key, SquarePen, CircleHelp } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
|
||||
import JsonImportInput from '@/components/common/JsonImportInput.vue'
|
||||
@@ -410,6 +411,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
function isRawSecretAuthType(authType: string | null | undefined): authType is RawSecretAuthType {
|
||||
return authType === 'api_key' || authType === 'bearer'
|
||||
@@ -549,7 +551,7 @@ function toggleApiFormatHelp() {
|
||||
const authSecretLabel = computed(() => {
|
||||
if (form.value.auth_type === 'service_account') return 'Service Account JSON'
|
||||
if (form.value.auth_type === 'bearer') return 'Bearer Token'
|
||||
return 'API 密钥'
|
||||
return legacyT('API 密钥')
|
||||
})
|
||||
|
||||
const authSecretPlaceholder = computed(() =>
|
||||
@@ -618,11 +620,18 @@ function buildAllowAuthChannelMismatchFormatsPayload(): string[] {
|
||||
}
|
||||
|
||||
const serviceAccountDescription = computed(() => (
|
||||
props.editingKey
|
||||
legacyT(props.editingKey
|
||||
? '留空表示不修改;JSON 格式,包含 project_id、private_key 等字段'
|
||||
: 'JSON 格式,包含 project_id、private_key 等字段'
|
||||
: 'JSON 格式,包含 project_id、private_key 等字段')
|
||||
))
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (saving.value) {
|
||||
return legacyT(isEditMode.value ? '保存中...' : '添加中...')
|
||||
}
|
||||
return legacyT(isEditMode.value ? '保存' : '添加')
|
||||
})
|
||||
|
||||
// 默认认证类型
|
||||
function getDefaultAuthType(): ProviderKeyFormAuthType {
|
||||
return authTypeOptions.value[0]?.value || 'api_key'
|
||||
@@ -653,7 +662,10 @@ const autoFetchWarningMessage = computed(() => {
|
||||
? props.editingKey.allowed_models
|
||||
: []
|
||||
if (models.length === 0) return ''
|
||||
return `当前 Key 模型权限存在以下模型:${models.map(model => `“${model}”`).join('、')},开启自动获取后将被覆盖`
|
||||
const formattedModels = models.map(model => locale.value === 'en-US' ? `"${model}"` : `“${model}”`).join(locale.value === 'en-US' ? ', ' : '、')
|
||||
return locale.value === 'en-US'
|
||||
? `Current key model permissions include these models: ${formattedModels}. Enabling auto fetch will overwrite them.`
|
||||
: `当前 Key 模型权限存在以下模型:${formattedModels},开启自动获取后将被覆盖`
|
||||
})
|
||||
|
||||
// 检查是否正在切换认证类型
|
||||
@@ -896,32 +908,32 @@ function parseAuthConfig(): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
function handleServiceAccountImportError(payload: { message: string, title?: string }) {
|
||||
showError(payload.message, payload.title || '错误')
|
||||
showError(payload.message, payload.title ? legacyT(payload.title) : legacyT('错误'))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 必须有 providerId
|
||||
if (!props.providerId) {
|
||||
showError('无法保存:缺少提供商信息', '错误')
|
||||
showError(legacyT('无法保存:缺少提供商信息'), legacyT('错误'))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证认证信息
|
||||
if (form.value.auth_type === 'service_account') {
|
||||
if (!props.editingKey && !form.value.auth_config_text.trim()) {
|
||||
showError('请输入 Service Account JSON', '验证失败')
|
||||
showError(legacyT('请输入 Service Account JSON'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
// 验证 JSON 格式
|
||||
if (form.value.auth_config_text.trim()) {
|
||||
const parsed = parseAuthConfig()
|
||||
if (!parsed) {
|
||||
showError('Service Account JSON 格式无效', '验证失败')
|
||||
showError(legacyT('Service Account JSON 格式无效'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
// 验证必要字段
|
||||
if (!parsed.client_email || !parsed.private_key || !parsed.project_id) {
|
||||
showError('Service Account JSON 缺少必要字段 (client_email, private_key, project_id)', '验证失败')
|
||||
showError(legacyT('Service Account JSON 缺少必要字段 (client_email, private_key, project_id)'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -934,7 +946,7 @@ async function handleSave() {
|
||||
|
||||
// 验证至少选择一个 API 格式
|
||||
if (form.value.api_formats.length === 0) {
|
||||
showError('请至少选择一个 API 格式', '验证失败')
|
||||
showError(legacyT('请至少选择一个 API 格式'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -990,7 +1002,7 @@ async function handleSave() {
|
||||
}
|
||||
|
||||
await updateProviderKey(props.editingKey.id, updateData)
|
||||
success('密钥已更新', '成功')
|
||||
success(legacyT('密钥已更新'), legacyT('成功'))
|
||||
} else {
|
||||
// 新增模式
|
||||
await addProviderKey(props.providerId, {
|
||||
@@ -1013,7 +1025,7 @@ async function handleSave() {
|
||||
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
|
||||
})
|
||||
|
||||
success('密钥已添加', '成功')
|
||||
success(legacyT('密钥已添加'), legacyT('成功'))
|
||||
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||
emit('saved')
|
||||
clearForNextAdd()
|
||||
@@ -1023,8 +1035,8 @@ async function handleSave() {
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = parseApiError(err, '保存密钥失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = parseApiError(err, legacyT('保存密钥失败'))
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="添加账号"
|
||||
:title="legacyT('添加账号')"
|
||||
:icon="UserPlus"
|
||||
size="md"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -18,7 +18,7 @@
|
||||
:class="selectedProxyNodeId
|
||||
? 'text-blue-500 bg-blue-500/10 hover:bg-blue-500/20'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
:title="selectedProxyNodeId ? `代理: ${getSelectedNodeLabel()}` : '设置代理节点'"
|
||||
:title="selectedProxyNodeId ? `${legacyT('代理')}: ${getSelectedNodeLabel()}` : legacyT('设置代理节点')"
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -31,18 +31,18 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium">代理节点</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('代理节点') }}</span>
|
||||
<span
|
||||
v-if="!proxyNodesStore.loading && proxyNodesStore.onlineNodes.length === 0"
|
||||
class="text-[10px] text-muted-foreground"
|
||||
>· 前往「模块管理 · 代理节点」添加</span>
|
||||
>· {{ legacyT('前往「模块管理 · 代理节点」添加') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedProxyNodeId"
|
||||
class="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="selectedProxyNodeId = ''; proxyPopoverOpen = false"
|
||||
>
|
||||
清除
|
||||
{{ legacyT('清除') }}
|
||||
</button>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
@@ -51,7 +51,7 @@
|
||||
@update:model-value="(v: string) => { selectedProxyNodeId = v; proxyPopoverOpen = false }"
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
{{ selectedProxyNodeId ? `${providerCredentialActionLabel}、刷新、额度查询均走此代理` : '未设置,依次回退到提供商代理 → 系统代理' }}
|
||||
{{ selectedProxyNodeId ? proxyUsageDescription : legacyT('未设置,依次回退到提供商代理 → 系统代理') }}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -73,7 +73,7 @@
|
||||
]"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
{{ isDeviceBrowserProvider ? (isWindsurfProvider ? '浏览器登录' : '设备授权') : '获取授权' }}
|
||||
{{ authorizationModeLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
@@ -110,7 +110,7 @@
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
|
||||
@click="selectWindsurfLoginOption(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
{{ legacyT(opt.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -124,10 +124,10 @@
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
|
||||
{{ legacyT(device.status === 'expired' ? '授权已过期' : '授权失败') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ device.error || '请重试' }}
|
||||
{{ legacyT(device.error || '请重试') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -135,7 +135,7 @@
|
||||
variant="outline"
|
||||
@click="resetDevice"
|
||||
>
|
||||
重新开始
|
||||
{{ legacyT('重新开始') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备登录...
|
||||
{{ legacyT('正在准备登录...') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往登录</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('前往登录') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
@@ -168,7 +168,7 @@
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
{{ legacyT('打开') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -177,7 +177,7 @@
|
||||
@click="copyToClipboard(device.verification_uri_complete)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
{{ legacyT('复制') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!device.session_id"
|
||||
@@ -186,7 +186,7 @@
|
||||
:disabled="device.starting"
|
||||
@click="startDeviceAuth"
|
||||
>
|
||||
开始
|
||||
{{ legacyT('开始') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL 或 token</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('粘贴回调 URL 或 token') }}</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
@@ -210,7 +210,7 @@
|
||||
class="pl-6 flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
|
||||
<span>会话剩余 {{ deviceCountdownFormatted }}</span>
|
||||
<span>{{ sessionRemainingText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,10 +253,10 @@
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
|
||||
{{ legacyT(device.status === 'expired' ? '授权已过期' : '授权失败') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ device.error || '请重试' }}
|
||||
{{ legacyT(device.error || '请重试') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -264,7 +264,7 @@
|
||||
variant="outline"
|
||||
@click="resetDevice"
|
||||
>
|
||||
重新开始
|
||||
{{ legacyT('重新开始') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,7 +277,7 @@
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在注册设备...
|
||||
{{ legacyT('正在注册设备...') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@
|
||||
<div class="space-y-2 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往授权</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('前往授权') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
@@ -299,7 +299,7 @@
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
{{ legacyT('打开') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -308,7 +308,7 @@
|
||||
@click="copyToClipboard(device.verification_uri_complete)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
{{ legacyT('复制') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,7 +316,7 @@
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('粘贴回调 URL') }}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 pl-6">
|
||||
<Textarea
|
||||
@@ -345,16 +345,16 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
在浏览器中完成授权
|
||||
{{ legacyT('在浏览器中完成授权') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
授权完成后此页面将自动更新
|
||||
{{ legacyT('授权完成后此页面将自动更新') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
|
||||
<span>剩余 {{ deviceCountdownFormatted }}</span>
|
||||
<span>{{ remainingText }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -364,7 +364,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="w-3.5 h-3.5 text-primary" />
|
||||
<span class="text-[10px] text-muted-foreground">MFA 验证码</span>
|
||||
<span class="text-[10px] text-muted-foreground">{{ legacyT('MFA 验证码') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -372,7 +372,7 @@
|
||||
>{{ totp.code.value }}</span>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="复制验证码"
|
||||
:title="legacyT('复制验证码')"
|
||||
@click="copyToClipboard(totp.code.value)"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
@@ -401,7 +401,7 @@
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
|
||||
打开授权页面
|
||||
{{ legacyT('打开授权页面') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -423,14 +423,14 @@
|
||||
v-if="isSocialDeviceAuth"
|
||||
class="text-xs text-muted-foreground text-center"
|
||||
>
|
||||
授权后复制浏览器地址栏的 localhost 回调 URL。
|
||||
{{ legacyT('授权后复制浏览器地址栏的 localhost 回调 URL。') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="device.auth_type === 'builder_id'"
|
||||
class="text-xs text-muted-foreground text-center"
|
||||
>
|
||||
使用个人 AWS Builder ID 进行设备授权,无需额外配置。
|
||||
{{ legacyT('使用个人 AWS Builder ID 进行设备授权,无需额外配置。') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -458,7 +458,7 @@
|
||||
<ComboboxAnchor class="relative w-full">
|
||||
<ComboboxInput
|
||||
:display-value="() => device.region"
|
||||
placeholder="输入或选择 Region"
|
||||
:placeholder="legacyT('输入或选择 Region')"
|
||||
class="w-full h-8 px-2 pr-7 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
@input="(e: Event) => regionSearch = (e.target as HTMLInputElement).value"
|
||||
@@ -474,7 +474,7 @@
|
||||
>
|
||||
<ComboboxViewport>
|
||||
<ComboboxEmpty class="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{{ awsRegionsLoaded ? '无匹配结果,回车使用自定义值' : '加载中...' }}
|
||||
{{ awsRegionsLoaded ? legacyT('无匹配结果,回车使用自定义值') : legacyT('加载中...') }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="r in filteredRegions"
|
||||
@@ -493,7 +493,7 @@
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选, 2FA认证)</label>
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ legacyT('TOTP Secret (可选, 2FA认证)') }}</label>
|
||||
<input
|
||||
v-model="device.totp_secret"
|
||||
type="text"
|
||||
@@ -509,7 +509,7 @@
|
||||
:disabled="device.starting || (device.auth_type === 'identity_center' && !device.start_url.trim())"
|
||||
@click="startDeviceAuth"
|
||||
>
|
||||
{{ device.starting ? '正在准备授权...' : '开始授权' }}
|
||||
{{ device.starting ? legacyT('正在准备授权...') : legacyT('开始授权') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,7 +525,7 @@
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备授权...
|
||||
{{ legacyT('正在准备授权...') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,7 +534,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往授权</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('前往授权') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
@@ -543,7 +543,7 @@
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
{{ legacyT('打开') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -552,7 +552,7 @@
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
{{ legacyT('复制') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,7 +560,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('粘贴回调 URL') }}</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
@@ -597,9 +597,9 @@
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing"
|
||||
@click="setWindsurfImportMethod(method.key)"
|
||||
>
|
||||
{{ method.label }}
|
||||
</button>
|
||||
>
|
||||
{{ legacyT(method.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -607,7 +607,7 @@
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">邮箱</label>
|
||||
<label class="text-xs font-medium">{{ legacyT('邮箱') }}</label>
|
||||
<input
|
||||
v-model="windsurfEmail"
|
||||
type="email"
|
||||
@@ -619,24 +619,24 @@
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">密码</label>
|
||||
<label class="text-xs font-medium">{{ legacyT('密码') }}</label>
|
||||
<input
|
||||
v-model="windsurfPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:disabled="importing"
|
||||
placeholder="Windsurf 密码"
|
||||
:placeholder="legacyT('Windsurf 密码')"
|
||||
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground">名称(可选)</label>
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ legacyT('名称(可选)') }}</label>
|
||||
<input
|
||||
v-model="windsurfAccountName"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="importing"
|
||||
placeholder="未填写时使用邮箱"
|
||||
:placeholder="legacyT('未填写时使用邮箱')"
|
||||
class="w-full h-9 px-2.5 text-xs rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
>
|
||||
@@ -677,8 +677,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>进度 {{ importTask.processed }}/{{ importTask.total }}</span>
|
||||
<span>成功 {{ importTask.success }} · 失败 {{ importTask.failed }}</span>
|
||||
<span>{{ importProgressText(importTask) }}</span>
|
||||
<span>{{ importResultSummaryText(importTask) }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="importTaskMessageText"
|
||||
@@ -691,14 +691,14 @@
|
||||
class="space-y-1"
|
||||
>
|
||||
<p class="text-[11px] text-destructive">
|
||||
最近错误
|
||||
{{ legacyT('最近错误') }}
|
||||
</p>
|
||||
<p
|
||||
v-for="item in importTask.error_samples.slice(0, 3)"
|
||||
:key="`${item.index}-${item.error || item.status}`"
|
||||
class="text-[11px] text-destructive/90"
|
||||
>
|
||||
#{{ item.index + 1 }} {{ item.error || '导入失败' }}
|
||||
{{ importErrorSampleText(item) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -711,21 +711,21 @@
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && showAuthorizationMode && !isDeviceBrowserProvider"
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||
{{ oauth.completing ? legacyT('验证中...') : legacyT('验证') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && isManualDeviceCallbackMode"
|
||||
:disabled="!canCompleteDeviceAuth"
|
||||
@click="completeDeviceAuth"
|
||||
>
|
||||
{{ device.completing ? '验证中...' : '验证' }}
|
||||
{{ device.completing ? legacyT('验证中...') : legacyT('验证') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'import'"
|
||||
@@ -756,6 +756,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useTotp } from '@/composables/useTotp'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
completeProviderLevelOAuth,
|
||||
@@ -788,6 +789,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { legacyT, locale } = useI18n()
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
const totp = useTotp()
|
||||
|
||||
@@ -834,6 +836,14 @@ function getSelectedNodeLabel(): string {
|
||||
return node ? node.name : `${selectedProxyNodeId.value.slice(0, 8) }...`
|
||||
}
|
||||
|
||||
function isEnglishLocale(): boolean {
|
||||
return locale.value === 'en-US'
|
||||
}
|
||||
|
||||
function localizedApiError(error: unknown, fallback: string): string {
|
||||
return legacyT(parseApiError(error, fallback))
|
||||
}
|
||||
|
||||
// 模式
|
||||
type DialogMode = 'oauth' | 'import'
|
||||
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
|
||||
@@ -959,9 +969,15 @@ const isManualDeviceCallbackPending = computed(() =>
|
||||
&& device.value.status === 'pending'
|
||||
)
|
||||
|
||||
const authorizationModeLabel = computed(() => {
|
||||
if (isWindsurfProvider.value) return legacyT('浏览器登录')
|
||||
if (isDeviceBrowserProvider.value) return legacyT('设备授权')
|
||||
return legacyT('获取授权')
|
||||
})
|
||||
|
||||
const deviceCallbackPlaceholder = computed(() =>
|
||||
isWindsurfProvider.value
|
||||
? `粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权`
|
||||
? legacyT('粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权')
|
||||
: `http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
|
||||
)
|
||||
|
||||
@@ -972,6 +988,18 @@ const deviceCountdownFormatted = computed(() => {
|
||||
return `${min}:${String(sec).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const sessionRemainingText = computed(() => (
|
||||
isEnglishLocale()
|
||||
? `Session remaining ${deviceCountdownFormatted.value}`
|
||||
: `会话剩余 ${deviceCountdownFormatted.value}`
|
||||
))
|
||||
|
||||
const remainingText = computed(() => (
|
||||
isEnglishLocale()
|
||||
? `${deviceCountdownFormatted.value} remaining`
|
||||
: `剩余 ${deviceCountdownFormatted.value}`
|
||||
))
|
||||
|
||||
const oauthBusy = computed(() =>
|
||||
oauth.value.starting || oauth.value.completing
|
||||
)
|
||||
@@ -997,33 +1025,42 @@ const canImport = computed(() => {
|
||||
return importText.value.trim().length > 0 && !importing.value
|
||||
})
|
||||
|
||||
const importModeLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入授权'))
|
||||
const importButtonLabel = computed(() => (isGrokProvider.value ? '导入账号' : '导入'))
|
||||
const importModeLabel = computed(() => legacyT(isGrokProvider.value ? '导入账号' : '导入授权'))
|
||||
const importButtonLabel = computed(() => legacyT(isGrokProvider.value ? '导入账号' : '导入'))
|
||||
const importDropTitle = computed(() => (
|
||||
isGrokProvider.value ? '拖入 Grok 账号文件或点击选择' : '拖入授权文件或点击选择'
|
||||
legacyT(isGrokProvider.value ? '拖入 Grok 账号文件或点击选择' : '拖入授权文件或点击选择')
|
||||
))
|
||||
const importDropHint = computed(() => (
|
||||
isGrokProvider.value ? '支持 .json / .txt,可多选、批量导入' : '支持 .json / .txt,可多选'
|
||||
legacyT(isGrokProvider.value ? '支持 .json / .txt,可多选、批量导入' : '支持 .json / .txt,可多选')
|
||||
))
|
||||
const importManualPlaceholder = computed(() => (
|
||||
isGrokProvider.value
|
||||
? '粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON'
|
||||
? legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
|
||||
: isWindsurfProvider.value
|
||||
? '粘贴 show-auth-token Token、API key 或 JSON 内容'
|
||||
: '粘贴 Refresh Token / Access Token 或 JSON 内容'
|
||||
? legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
|
||||
: legacyT('粘贴 Refresh Token / Access Token 或 JSON 内容')
|
||||
))
|
||||
const importManualDescription = computed(() => (
|
||||
isGrokProvider.value
|
||||
? 'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。'
|
||||
? legacyT('plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。')
|
||||
: ''
|
||||
))
|
||||
const importPasteToggleText = computed(() => (
|
||||
isGrokProvider.value ? '或手动粘贴 Grok Token' : '或手动粘贴 Token'
|
||||
legacyT(isGrokProvider.value ? '或手动粘贴 Grok Token' : '或手动粘贴 Token')
|
||||
))
|
||||
const importFileToggleText = computed(() => (
|
||||
isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入'
|
||||
legacyT(isGrokProvider.value ? '或选择 Grok Token 文件导入' : '或选择 JSON 文件导入')
|
||||
))
|
||||
const providerCredentialActionLabel = computed(() => (isGrokProvider.value ? '导入' : '授权'))
|
||||
const proxyUsageDescription = computed(() => {
|
||||
if (isEnglishLocale()) {
|
||||
return isGrokProvider.value
|
||||
? 'Import, refresh, and quota queries use this proxy'
|
||||
: 'Authorization, refresh, and quota queries use this proxy'
|
||||
}
|
||||
return isGrokProvider.value
|
||||
? '导入、刷新、额度查询均走此代理'
|
||||
: '授权、刷新、额度查询均走此代理'
|
||||
})
|
||||
const isWindsurfEmailPasswordImport = computed(() =>
|
||||
isWindsurfProvider.value && windsurfImportMethod.value === 'email_password'
|
||||
)
|
||||
@@ -1031,19 +1068,35 @@ const isWindsurfEmailPasswordImport = computed(() =>
|
||||
const importButtonText = computed(() => {
|
||||
if (importing.value) {
|
||||
return importTask.value && !isWindsurfEmailPasswordImport.value
|
||||
? `导入中 ${importTask.value.progress_percent}%`
|
||||
: '导入中...'
|
||||
? (isEnglishLocale() ? `Importing ${importTask.value.progress_percent}%` : `导入中 ${importTask.value.progress_percent}%`)
|
||||
: legacyT('导入中...')
|
||||
}
|
||||
return isWindsurfEmailPasswordImport.value ? '登录并导入' : importButtonLabel.value
|
||||
return isWindsurfEmailPasswordImport.value ? legacyT('登录并导入') : importButtonLabel.value
|
||||
})
|
||||
|
||||
const importTaskMessageText = computed(() => {
|
||||
const message = importTask.value?.message?.trim()
|
||||
if (!message) return ''
|
||||
// 后端进度 message 已由“进度 x/y”展示,避免在导入中重复显示“处理中 x/y”。
|
||||
return redundantImportTaskMessagePattern.test(message) ? '' : message
|
||||
return redundantImportTaskMessagePattern.test(message) ? '' : legacyT(message)
|
||||
})
|
||||
|
||||
function importProgressText(task: OAuthBatchImportTaskStatusResponse): string {
|
||||
return isEnglishLocale()
|
||||
? `Progress ${task.processed}/${task.total}`
|
||||
: `进度 ${task.processed}/${task.total}`
|
||||
}
|
||||
|
||||
function importResultSummaryText(task: OAuthBatchImportTaskStatusResponse): string {
|
||||
return isEnglishLocale()
|
||||
? `Success ${task.success} · Failed ${task.failed}`
|
||||
: `成功 ${task.success} · 失败 ${task.failed}`
|
||||
}
|
||||
|
||||
function importErrorSampleText(item: OAuthBatchImportTaskStatusResponse['error_samples'][number]): string {
|
||||
return `#${item.index + 1} ${legacyT(item.error || '导入失败')}`
|
||||
}
|
||||
|
||||
function stopImportPolling() {
|
||||
if (importPollTimer) {
|
||||
clearTimeout(importPollTimer)
|
||||
@@ -1055,15 +1108,15 @@ function stopImportPolling() {
|
||||
function getImportTaskStatusText(status: OAuthBatchImportTaskStatus): string {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return '任务已提交'
|
||||
return legacyT('任务已提交')
|
||||
case 'processing':
|
||||
return '正在导入'
|
||||
return legacyT('正在导入')
|
||||
case 'completed':
|
||||
return '导入完成'
|
||||
return legacyT('导入完成')
|
||||
case 'failed':
|
||||
return '导入失败'
|
||||
return legacyT('导入失败')
|
||||
default:
|
||||
return '处理中'
|
||||
return legacyT('处理中')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,6 +1126,19 @@ function getOAuthSuccessMessage(
|
||||
): string {
|
||||
const email = typeof options?.email === 'string' ? options.email.trim() : ''
|
||||
const replaced = options?.replaced === true
|
||||
const actionText = legacyT(action)
|
||||
|
||||
if (isEnglishLocale()) {
|
||||
if (email) {
|
||||
return replaced
|
||||
? `${actionText} succeeded: ${email} (replaced existing account)`
|
||||
: `${actionText} succeeded: ${email}`
|
||||
}
|
||||
return replaced
|
||||
? `${actionText} succeeded; replaced existing account`
|
||||
: `${actionText} succeeded; account added`
|
||||
}
|
||||
|
||||
if (email) {
|
||||
return replaced
|
||||
? `${action}成功: ${email}(已替换旧账号)`
|
||||
@@ -1089,27 +1155,36 @@ function getBatchImportSuccessMessage(task: OAuthBatchImportTaskStatusResponse):
|
||||
const parts: string[] = []
|
||||
|
||||
if (createdCount > 0) {
|
||||
parts.push(`新增 ${createdCount} 个`)
|
||||
parts.push(isEnglishLocale() ? `${createdCount} added` : `新增 ${createdCount} 个`)
|
||||
}
|
||||
if (replacedCount > 0) {
|
||||
parts.push(`替换 ${replacedCount} 个`)
|
||||
parts.push(isEnglishLocale() ? `${replacedCount} replaced` : `替换 ${replacedCount} 个`)
|
||||
}
|
||||
if (task.failed > 0) {
|
||||
parts.push(`失败 ${task.failed} 个`)
|
||||
parts.push(isEnglishLocale() ? `${task.failed} failed` : `失败 ${task.failed} 个`)
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
if (isEnglishLocale()) {
|
||||
return task.failed > 0 ? `Batch import complete: ${task.failed} failed` : 'Batch import complete'
|
||||
}
|
||||
return task.failed > 0 ? `批量导入完成:失败 ${task.failed} 个` : '批量导入完成'
|
||||
}
|
||||
if (task.failed === 0 && createdCount > 0 && replacedCount === 0) {
|
||||
if (isEnglishLocale()) return `Batch import succeeded: ${createdCount} accounts added`
|
||||
return `批量导入成功:${createdCount} 个账号已添加`
|
||||
}
|
||||
if (task.failed === 0 && createdCount === 0 && replacedCount > 0) {
|
||||
if (isEnglishLocale()) return `Batch import succeeded: ${replacedCount} existing accounts replaced`
|
||||
return `批量导入成功:已替换 ${replacedCount} 个旧账号`
|
||||
}
|
||||
|
||||
const prefix = task.failed > 0 ? '批量导入完成' : '批量导入成功'
|
||||
return `${prefix}:${parts.join(',')}`
|
||||
const prefix = task.failed > 0
|
||||
? legacyT('批量导入完成')
|
||||
: legacyT('批量导入成功')
|
||||
return isEnglishLocale()
|
||||
? `${prefix}: ${parts.join(', ')}`
|
||||
: `${prefix}:${parts.join(',')}`
|
||||
}
|
||||
|
||||
function scheduleImportPoll(taskId: string, delayMs = 1200) {
|
||||
@@ -1135,7 +1210,7 @@ async function pollImportTaskStatus(taskId: string) {
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} else {
|
||||
showError(task.error || '批量导入失败', '导入失败')
|
||||
showError(legacyT(task.error || '批量导入失败'), legacyT('导入失败'))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1143,7 +1218,7 @@ async function pollImportTaskStatus(taskId: string) {
|
||||
if (task.status === 'failed') {
|
||||
stopImportPolling()
|
||||
importing.value = false
|
||||
showError(task.error || task.message || '批量导入失败', '导入失败')
|
||||
showError(legacyT(task.error || task.message || '批量导入失败'), legacyT('导入失败'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1305,8 +1380,8 @@ async function initOAuth() {
|
||||
oauth.value.provider_type = resp.provider_type
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== oauthInitRequestId) return
|
||||
const errorMessage = parseApiError(err, '初始化授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '初始化授权失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
mode.value = 'import'
|
||||
} finally {
|
||||
if (requestId === oauthInitRequestId) {
|
||||
@@ -1331,8 +1406,8 @@ async function handleCompleteOAuth() {
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== oauthCompleteRequestId) return
|
||||
const errorMessage = parseApiError(err, '完成授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '完成授权失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
} finally {
|
||||
if (requestId === oauthCompleteRequestId) {
|
||||
oauth.value.completing = false
|
||||
@@ -1618,7 +1693,7 @@ function decodeBase64Url(value: string): string {
|
||||
}
|
||||
|
||||
function handleImportInputError(payload: { message: string; title?: string }) {
|
||||
showError(payload.message, payload.title)
|
||||
showError(legacyT(payload.message), payload.title ? legacyT(payload.title) : undefined)
|
||||
}
|
||||
|
||||
function setWindsurfImportMethod(method: WindsurfImportMethod) {
|
||||
@@ -1633,7 +1708,7 @@ async function handleWindsurfEmailPasswordImport() {
|
||||
const email = windsurfEmail.value.trim()
|
||||
const password = windsurfPassword.value.trim()
|
||||
if (!email || !password) {
|
||||
showError('请输入邮箱和密码', '格式错误')
|
||||
showError(legacyT('请输入邮箱和密码'), legacyT('格式错误'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1649,8 +1724,8 @@ async function handleWindsurfEmailPasswordImport() {
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = parseApiError(err, '导入失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '导入失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
@@ -1665,13 +1740,13 @@ async function handleImport() {
|
||||
|
||||
const inputText = importText.value.trim()
|
||||
if (!inputText) {
|
||||
showError('请输入凭据数据', '格式错误')
|
||||
showError(legacyT('请输入凭据数据'), legacyT('格式错误'))
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
|
||||
if (!normalizedCredentials.ok) {
|
||||
showError(normalizedCredentials.message, '格式错误')
|
||||
showError(legacyT(normalizedCredentials.message), legacyT('格式错误'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1708,7 +1783,7 @@ async function handleImport() {
|
||||
// 单条导入
|
||||
const parsed = parseImportText(normalizedCredentials.credentials)
|
||||
if (!parsed) {
|
||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||
showError(legacyT('无法解析输入内容,请检查格式'), legacyT('格式错误'))
|
||||
return
|
||||
}
|
||||
const result = await importProviderRefreshToken(props.providerId, {
|
||||
@@ -1720,8 +1795,8 @@ async function handleImport() {
|
||||
handleClose()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = parseApiError(err, '导入失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '导入失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
} finally {
|
||||
if (!keepImporting) {
|
||||
importing.value = false
|
||||
@@ -1796,8 +1871,8 @@ async function startDeviceAuth() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
|
||||
const errorMessage = parseApiError(err, '发起设备授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '发起设备授权失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
device.value.status = 'error'
|
||||
device.value.error = errorMessage
|
||||
} finally {
|
||||
@@ -1895,8 +1970,8 @@ async function pollDevice(withCallback = false) {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (withCallback) {
|
||||
const errorMessage = parseApiError(err, '完成授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
const errorMessage = localizedApiError(err, '完成授权失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
}
|
||||
// 网络错误等,继续轮询
|
||||
if (!withCallback && !device.value.callback_required) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
title="优先级管理"
|
||||
description="拖拽调整顺序,点击序号可编辑(相同数字为同级),保存后自动切换对应的调度策略"
|
||||
:title="legacyT('优先级管理')"
|
||||
:description="legacyT('拖拽调整顺序,点击序号可编辑(相同数字为同级),保存后自动切换对应的调度策略')"
|
||||
:icon="ListOrdered"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -21,7 +21,7 @@
|
||||
@click="activeMainTab = 'provider'"
|
||||
>
|
||||
<Layers class="w-4 h-4" />
|
||||
<span>提供商优先</span>
|
||||
<span>{{ legacyT('提供商优先') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -34,7 +34,7 @@
|
||||
@click="activeMainTab = 'key'"
|
||||
>
|
||||
<Key class="w-4 h-4" />
|
||||
<span>Key 优先</span>
|
||||
<span>{{ legacyT('Key 优先') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
||||
>
|
||||
<Layers class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无提供商</span>
|
||||
<span class="text-sm">{{ legacyT('暂无提供商') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 提供商列表 -->
|
||||
@@ -98,7 +98,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
title="点击编辑优先级,相同数字为同级"
|
||||
:title="legacyT('点击编辑优先级,相同数字为同级')"
|
||||
@click.stop="startEditProviderPriority(provider)"
|
||||
>
|
||||
{{ provider.provider_priority }}
|
||||
@@ -113,7 +113,7 @@
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 h-5 shrink-0"
|
||||
>
|
||||
停用
|
||||
{{ legacyT('停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 ml-2">
|
||||
@@ -156,7 +156,7 @@
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-2 border-muted border-t-primary" />
|
||||
<span class="text-xs text-muted-foreground">加载中...</span>
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('加载中...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
||||
>
|
||||
<Key class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无 API Key</span>
|
||||
<span class="text-sm">{{ legacyT('暂无 API Key') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 左右布局:格式列表 + Key 列表 -->
|
||||
@@ -261,7 +261,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium transition-colors text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||
title="点击编辑优先级"
|
||||
:title="legacyT('点击编辑优先级')"
|
||||
@click.stop="startEditKeyPriority(format, key)"
|
||||
>
|
||||
{{ key.priority }}
|
||||
@@ -281,28 +281,28 @@
|
||||
variant="outline"
|
||||
class="text-[9px] h-4 px-1 shrink-0"
|
||||
>
|
||||
号池
|
||||
{{ legacyT('号池') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="key.circuit_breaker_open"
|
||||
variant="destructive"
|
||||
class="text-[9px] h-4 px-1 shrink-0"
|
||||
>
|
||||
熔断
|
||||
{{ legacyT('熔断') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="!key.is_active && key.provider_active"
|
||||
variant="secondary"
|
||||
class="text-[9px] h-4 px-1 shrink-0"
|
||||
>
|
||||
停用
|
||||
{{ legacyT('停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 第二行:密钥脱敏 · Provider 名称 + Provider 级别状态 -->
|
||||
<div class="flex items-center gap-0 mt-0.5">
|
||||
<template v-if="key.is_pool_aggregate">
|
||||
<span class="text-[10px] text-muted-foreground/70 truncate">
|
||||
号池: {{ key.pool_active_key_count ?? 0 }}/{{ key.pool_key_count ?? 0 }}
|
||||
{{ poolKeySummary(key) }}
|
||||
</span>
|
||||
<template v-if="key.provider_type">
|
||||
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
||||
@@ -319,7 +319,7 @@
|
||||
variant="secondary"
|
||||
class="text-[9px] h-4 px-1 shrink-0 ml-1"
|
||||
>
|
||||
停用
|
||||
{{ legacyT('停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +341,7 @@
|
||||
--
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">
|
||||
{{ key.is_pool_aggregate ? 'Pool' : (key.rate_multipliers?.[format] ?? 1) + 'x' }}
|
||||
{{ key.is_pool_aggregate ? legacyT('Pool') : (key.rate_multipliers?.[format] ?? 1) + 'x' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 快捷启用/禁用开关 -->
|
||||
@@ -353,12 +353,12 @@
|
||||
? 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
:title="key.is_pool_aggregate
|
||||
? '号池聚合项不支持在此单独开关'
|
||||
? legacyT('号池聚合项不支持在此单独开关')
|
||||
: !key.provider_active
|
||||
? 'Provider 停用'
|
||||
? legacyT('Provider 停用')
|
||||
: key.is_active
|
||||
? '点击停用'
|
||||
: '点击启用'"
|
||||
? legacyT('点击停用')
|
||||
: legacyT('点击启用')"
|
||||
:disabled="key.is_pool_aggregate || !key.provider_active"
|
||||
@click.stop="!key.is_pool_aggregate && toggleKeyActive(format, key)"
|
||||
>
|
||||
@@ -373,7 +373,7 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
||||
>
|
||||
<Key class="w-10 h-10 mb-3 opacity-20" />
|
||||
<span class="text-sm">暂无 {{ format }} 格式的 Key</span>
|
||||
<span class="text-sm">{{ emptyFormatKeyText(format) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,10 +386,10 @@
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
当前模式: <span class="font-medium text-foreground/80">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
|
||||
{{ legacyT('当前模式:') }} <span class="font-medium text-foreground/80">{{ activeMainTabLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 pl-3 border-l border-border/60">
|
||||
<span class="text-xs text-muted-foreground">调度:</span>
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('调度:') }}</span>
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
@@ -399,10 +399,10 @@
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
title="优先使用已缓存的Provider,利用Prompt Cache"
|
||||
:title="legacyT('优先使用已缓存的Provider,利用Prompt Cache')"
|
||||
@click="schedulingMode = 'cache_affinity'"
|
||||
>
|
||||
缓存亲和
|
||||
{{ legacyT('缓存亲和') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,10 +412,10 @@
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
title="同优先级内随机轮换,不考虑缓存"
|
||||
:title="legacyT('同优先级内随机轮换,不考虑缓存')"
|
||||
@click="schedulingMode = 'load_balance'"
|
||||
>
|
||||
负载均衡
|
||||
{{ legacyT('负载均衡') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -425,10 +425,10 @@
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
title="严格按优先级顺序,不考虑缓存"
|
||||
:title="legacyT('严格按优先级顺序,不考虑缓存')"
|
||||
@click="schedulingMode = 'fixed_order'"
|
||||
>
|
||||
固定顺序
|
||||
{{ legacyT('固定顺序') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@
|
||||
v-if="saving"
|
||||
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
||||
/>
|
||||
{{ saving ? '保存中' : '保存' }}
|
||||
{{ saving ? legacyT('保存中') : legacyT('保存') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -452,7 +452,7 @@
|
||||
class="min-w-[72px]"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,6 +468,7 @@ import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { updateProvider, updateProviderKey } from '@/api/endpoints'
|
||||
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -519,6 +520,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -562,6 +564,22 @@ const schedulingMode = ref<'fixed_order' | 'load_balance' | 'cache_affinity'>('c
|
||||
// 余额数据缓存 {providerId: ActionResultResponse}
|
||||
const balanceCache = ref<Record<string, ActionResultResponse>>({})
|
||||
|
||||
const activeMainTabLabel = computed(() =>
|
||||
legacyT(activeMainTab.value === 'provider' ? '提供商优先' : 'Key 优先')
|
||||
)
|
||||
|
||||
function poolKeySummary(key: KeyWithMeta): string {
|
||||
return `${legacyT('号池')}: ${key.pool_active_key_count ?? 0}/${key.pool_key_count ?? 0}`
|
||||
}
|
||||
|
||||
function emptyFormatKeyText(format: string): string {
|
||||
return legacyT(`暂无 ${format} 格式的 Key`)
|
||||
}
|
||||
|
||||
function localizedApiError(error: unknown, fallback: string): string {
|
||||
return legacyT(parseApiError(error, fallback))
|
||||
}
|
||||
|
||||
// 类型守卫函数
|
||||
function isBalanceInfo(data: unknown): data is BalanceInfo {
|
||||
return data !== null && typeof data === 'object' && 'total_available' in data
|
||||
@@ -863,7 +881,7 @@ const PROVIDER_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
function formatProviderType(type?: string): string {
|
||||
if (!type) return ''
|
||||
return PROVIDER_TYPE_LABELS[type] || type
|
||||
return legacyT(PROVIDER_TYPE_LABELS[type] || type)
|
||||
}
|
||||
|
||||
function isPoolManagedProvider(providerId: string): boolean {
|
||||
@@ -928,7 +946,7 @@ function buildPoolAggregateItem(format: string, providerId: string, sourceKeys:
|
||||
? Number(poolPriorityRaw)
|
||||
: fallbackPriority
|
||||
|
||||
const providerName = provider?.name || sourceKeys[0]?.provider_name || '未知 Provider'
|
||||
const providerName = provider?.name || sourceKeys[0]?.provider_name || 'Unknown Provider'
|
||||
const activeKeyCount = sourceKeys.filter((k) => k.is_active).length
|
||||
const providerActive = provider?.is_active ?? sourceKeys.some((k) => k.provider_active)
|
||||
const healthCandidates = sourceKeys.map((k) => k.health_score).filter((v): v is number => v != null)
|
||||
@@ -1171,7 +1189,7 @@ async function loadKeysByFormat() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
originalKeyPriorityById = new Map()
|
||||
showError(parseApiError(err, '加载 Key 列表失败'), '错误')
|
||||
showError(localizedApiError(err, '加载 Key 列表失败'), legacyT('错误'))
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
@@ -1195,9 +1213,9 @@ async function toggleKeyActive(format: string, key: KeyWithMeta) {
|
||||
for (const fmt of Object.keys(keysByFormat.value)) {
|
||||
keysByFormat.value[fmt] = sortKeysByActiveAndPriority(keysByFormat.value[fmt])
|
||||
}
|
||||
success(newStatus ? 'Key 已启用' : 'Key 已停用')
|
||||
success(legacyT(newStatus ? 'Key 已启用' : 'Key 已停用'))
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '操作失败'), '错误')
|
||||
showError(localizedApiError(err, '操作失败'), legacyT('错误'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,7 +1557,7 @@ async function save() {
|
||||
)
|
||||
snapshotCurrentPriorityBaseline()
|
||||
|
||||
success('优先级已保存')
|
||||
success(legacyT('优先级已保存'))
|
||||
emit('saved')
|
||||
|
||||
// 提供商优先模式保存后关闭,Key 优先模式保存后保持打开方便继续调整
|
||||
@@ -1547,7 +1565,7 @@ async function save() {
|
||||
close()
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '保存失败'), '错误')
|
||||
showError(localizedApiError(err, '保存失败'), legacyT('错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
<span>{{ legacyT('加载中...') }}</span>
|
||||
</div>
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
@@ -75,7 +75,7 @@
|
||||
<span
|
||||
class="text-[10px] text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
>{{ legacyT('签到 Cookie 已失效') }}</span>
|
||||
</div>
|
||||
<!-- 签到状态 -->
|
||||
<div
|
||||
@@ -86,12 +86,12 @@
|
||||
v-if="getProviderCheckin(provider.id)?.success !== false"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
>{{ legacyT('已签到') }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
>{{ legacyT('签到失败') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +133,9 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<Card
|
||||
v-if="progress"
|
||||
class="border-primary/30 bg-primary/5"
|
||||
>
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-foreground">
|
||||
{{ legacyT('正在删除提供商') }}: {{ progress.providerName }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ stageLabel }} · {{ legacyT(progress.message || '后台处理中') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-xs font-medium text-primary">
|
||||
{{ overallPercent }}%
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ completedUnits }}/{{ totalUnits }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ legacyT('总体进度') }}</span>
|
||||
<span>{{ completedUnits }}/{{ totalUnits }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
:style="{ width: `${overallPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ legacyT('账号删除') }}</span>
|
||||
<span>{{ progress.deletedKeys }}/{{ progress.totalKeys || '...' }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary/80 transition-all duration-300"
|
||||
:style="{ width: `${keysPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ legacyT('端点删除') }}</span>
|
||||
<span>{{ progress.deletedEndpoints }}/{{ progress.totalEndpoints || '...' }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary/60 transition-all duration-300"
|
||||
:style="{ width: `${endpointsPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface ProviderDeleteProgressView {
|
||||
providerName: string
|
||||
totalKeys: number
|
||||
deletedKeys: number
|
||||
totalEndpoints: number
|
||||
deletedEndpoints: number
|
||||
message: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
progress: ProviderDeleteProgressView | null
|
||||
stageLabel: string
|
||||
totalUnits: number
|
||||
completedUnits: number
|
||||
overallPercent: number
|
||||
keysPercent: number
|
||||
endpointsPercent: number
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="sticky top-0 z-10 bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-3">
|
||||
<div class="flex items-center justify-between gap-x-3 sm:gap-x-4 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-lg sm:text-xl font-bold truncate">
|
||||
{{ provider.name }}
|
||||
</h2>
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'default' : 'secondary'"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<span :title="formatConversionTitle">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="(provider.enable_format_conversion || systemFormatConversionEnabled) ? 'text-primary' : ''"
|
||||
:disabled="systemFormatConversionEnabled"
|
||||
@click="$emit('toggleFormatConversion')"
|
||||
>
|
||||
<Shuffle class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span :title="legacyT(hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="hasFailoverRules ? 'text-orange-500 dark:text-orange-400' : ''"
|
||||
@click="$emit('openFailoverRules')"
|
||||
>
|
||||
<GitBranch class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Popover
|
||||
:open="providerProxyPopoverOpen"
|
||||
@update:open="$emit('update:providerProxyPopoverOpen', $event)"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="provider.proxy?.node_id ? 'text-blue-500' : ''"
|
||||
:disabled="savingProviderProxy"
|
||||
:title="provider.proxy?.node_id ? `${legacyT('代理')}: ${providerProxyNodeName}` : legacyT('设置代理节点')"
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-72 p-3"
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium">{{ legacyT('代理节点') }}</span>
|
||||
<Button
|
||||
v-if="provider.proxy?.node_id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-[10px] text-muted-foreground"
|
||||
:disabled="savingProviderProxy"
|
||||
@click="$emit('clearProviderProxy')"
|
||||
>
|
||||
{{ legacyT('清除') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
:model-value="provider.proxy?.node_id || ''"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="$emit('setProviderProxy', $event)"
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
{{ legacyT(provider.proxy?.node_id ? '当前使用独立代理' : '未设置代理节点') }}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="legacyT('编辑提供商')"
|
||||
@click="$emit('edit', provider)"
|
||||
>
|
||||
<Edit class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="legacyT('关闭')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="provider.website"
|
||||
class="-mt-0.5"
|
||||
>
|
||||
<a
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground hover:text-primary hover:underline transition-colors truncate block"
|
||||
:title="provider.website"
|
||||
>{{ provider.website }}</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap mt-3">
|
||||
<template v-if="loadingProviderEndpoints && endpoints.length === 0">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
{{ legacyT('加载端点中') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.id"
|
||||
>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
|
||||
:class="{ 'opacity-40': !endpoint.is_active }"
|
||||
:title="legacyT('编辑端点')"
|
||||
@click="$emit('editEndpoint', endpoint)"
|
||||
>{{ formatApiFormat(endpoint.api_format) }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="endpoints.length > 0"
|
||||
class="text-xs px-2 py-0.5 rounded-md border border-dashed border-border hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors text-muted-foreground"
|
||||
:title="legacyT('编辑端点')"
|
||||
@click="$emit('addEndpoint')"
|
||||
>{{ legacyT('编辑') }}</span>
|
||||
<Button
|
||||
v-else
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="$emit('addEndpoint')"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
{{ legacyT('添加 API 端点') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Edit, GitBranch, Globe, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
endpoints: ProviderEndpoint[]
|
||||
loadingProviderEndpoints: boolean
|
||||
systemFormatConversionEnabled: boolean
|
||||
hasFailoverRules: boolean
|
||||
providerProxyPopoverOpen: boolean
|
||||
providerProxyNodeName: string
|
||||
savingProviderProxy: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggleFormatConversion'): void
|
||||
(e: 'openFailoverRules'): void
|
||||
(e: 'update:providerProxyPopoverOpen', value: boolean): void
|
||||
(e: 'setProviderProxy', value: string): void
|
||||
(e: 'clearProviderProxy'): void
|
||||
(e: 'edit', provider: ProviderWithEndpointsSummary): void
|
||||
(e: 'toggleStatus', provider: ProviderWithEndpointsSummary): void
|
||||
(e: 'close'): void
|
||||
(e: 'editEndpoint', endpoint: ProviderEndpoint): void
|
||||
(e: 'addEndpoint'): void
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const formatConversionTitle = computed(() => {
|
||||
if (props.systemFormatConversionEnabled) return legacyT('系统级格式转换已启用')
|
||||
if (props.provider.enable_format_conversion) return legacyT('已启用格式转换(点击关闭)')
|
||||
return legacyT('启用格式转换')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="mb-2 text-muted-foreground">
|
||||
{{ legacyT(hasActiveFilters ? '未找到匹配当前筛选条件的提供商' : '暂无提供商,点击右上角添加') }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$emit('resetFilters')"
|
||||
>
|
||||
{{ legacyT('清除筛选') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
hasActiveFilters: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resetFilters: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="internalOpen"
|
||||
:title="isEditMode ? '编辑提供商' : '添加提供商'"
|
||||
:description="isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。'"
|
||||
:title="legacyT(isEditMode ? '编辑提供商' : '添加提供商')"
|
||||
:description="legacyT(isEditMode ? '更新提供商配置。API 端点和密钥需在详情页面单独管理。' : '创建新的提供商配置。创建后可以为其添加 API 端点和密钥。')"
|
||||
:icon="isEditMode ? SquarePen : Server"
|
||||
size="xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -14,33 +14,33 @@
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
基本信息
|
||||
{{ legacyT('基本信息') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="name">名称 *</Label>
|
||||
<Label for="name">{{ legacyT('名称 *') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="例如: OpenAI 主账号"
|
||||
:placeholder="legacyT('例如: OpenAI 主账号')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>提供商类型</Label>
|
||||
<Label>{{ legacyT('提供商类型') }}</Label>
|
||||
<Select
|
||||
v-model="form.provider_type"
|
||||
:disabled="isEditMode"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择" />
|
||||
<SelectValue :placeholder="legacyT('请选择')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- 新建模式:允许自定义及各反代类型 -->
|
||||
<template v-if="!isEditMode">
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
{{ legacyT('自定义') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
@@ -49,7 +49,7 @@
|
||||
value="claude_code"
|
||||
disabled
|
||||
>
|
||||
ClaudeCode(暂不可用)
|
||||
{{ legacyT('ClaudeCode(暂不可用)') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -76,7 +76,7 @@
|
||||
<!-- 编辑模式:显示所有类型(兼容已有数据) -->
|
||||
<template v-else>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
{{ legacyT('自定义') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
@@ -112,15 +112,15 @@
|
||||
v-if="!isEditMode && form.provider_type !== 'custom'"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
反代使用固定端点且不可修改
|
||||
{{ legacyT('反代使用固定端点且不可修改') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="website">主站链接</Label>
|
||||
<Label for="website">{{ legacyT('主站链接') }}</Label>
|
||||
<Input
|
||||
id="website"
|
||||
v-model="form.website"
|
||||
placeholder="https://example.com(可选)"
|
||||
:placeholder="legacyT('https://example.com(可选)')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,15 +130,15 @@
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
计费与限流
|
||||
{{ legacyT('计费与限流') }}
|
||||
</h3>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
请求配置
|
||||
{{ legacyT('请求配置') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>计费类型</Label>
|
||||
<Label>{{ legacyT('计费类型') }}</Label>
|
||||
<Select
|
||||
v-model="form.billing_type"
|
||||
>
|
||||
@@ -147,25 +147,25 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly_quota">
|
||||
月卡额度
|
||||
{{ legacyT('月卡额度') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="pay_as_you_go">
|
||||
按量付费
|
||||
{{ legacyT('按量付费') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="free_tier">
|
||||
免费套餐
|
||||
{{ legacyT('免费套餐') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>最大重试次数</Label>
|
||||
<Label>{{ legacyT('最大重试次数') }}</Label>
|
||||
<Input
|
||||
:model-value="form.max_retries ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
placeholder="默认 2"
|
||||
:placeholder="legacyT('默认 2')"
|
||||
@update:model-value="(v) => form.max_retries = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -175,8 +175,8 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
流式首字节超时
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
{{ legacyT('流式首字节超时') }}
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.stream_first_byte_timeout ?? ''"
|
||||
@@ -190,8 +190,8 @@
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
非流式请求超时
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
{{ legacyT('非流式请求超时') }}
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.request_timeout ?? ''"
|
||||
@@ -211,7 +211,7 @@
|
||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">周期额度 (USD)</Label>
|
||||
<Label class="text-xs">{{ legacyT('周期额度 (USD)') }}</Label>
|
||||
<Input
|
||||
:model-value="form.monthly_quota_usd ?? ''"
|
||||
type="number"
|
||||
@@ -221,7 +221,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">重置周期 (天)</Label>
|
||||
<Label class="text-xs">{{ legacyT('重置周期 (天)') }}</Label>
|
||||
<Input
|
||||
:model-value="form.quota_reset_day ?? ''"
|
||||
type="number"
|
||||
@@ -232,7 +232,7 @@
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">
|
||||
周期开始时间 <span class="text-red-500">*</span>
|
||||
{{ legacyT('周期开始时间') }} <span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
v-model="form.quota_last_reset_at"
|
||||
@@ -240,7 +240,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">过期时间</Label>
|
||||
<Label class="text-xs">{{ legacyT('过期时间') }}</Label>
|
||||
<Input
|
||||
v-model="form.quota_expires_at"
|
||||
type="datetime-local"
|
||||
@@ -252,14 +252,14 @@
|
||||
<!-- 功能开关 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
功能开关
|
||||
{{ legacyT('功能开关') }}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">格式转换保持优先级</span>
|
||||
<span class="text-sm font-medium">{{ legacyT('格式转换保持优先级') }}</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后
|
||||
{{ legacyT('跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -270,9 +270,9 @@
|
||||
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">号池调度模式</span>
|
||||
<span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
启用后该提供商的密钥将由号池统一调度
|
||||
{{ legacyT('启用后该提供商的密钥将由号池统一调度') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -286,9 +286,9 @@
|
||||
class="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">模拟缓存模式</span>
|
||||
<span class="text-sm font-medium">{{ legacyT('模拟缓存模式') }}</span>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
启用后仅对 Kiro 请求模拟 prompt cache 读写计量。
|
||||
{{ legacyT('启用后仅对 Kiro 请求模拟 prompt cache 读写计量。') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -299,9 +299,9 @@
|
||||
|
||||
<div class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">敏感信息保护</span>
|
||||
<span class="text-sm font-medium">{{ legacyT('敏感信息保护') }}</span>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
请前往模块管理-敏感信息保护中配置详细规则。
|
||||
{{ legacyT('请前往模块管理-敏感信息保护中配置详细规则。') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,13 +315,13 @@
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || !form.name"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存' : '创建') }}
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -344,6 +344,7 @@ import {
|
||||
import { Server, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
createProvider,
|
||||
normalizePoolAdvancedConfig,
|
||||
@@ -368,6 +369,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
// 内部状态
|
||||
@@ -381,6 +383,13 @@ const defaultPriority = computed(() => {
|
||||
return 100
|
||||
})
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (loading.value) {
|
||||
return legacyT(isEditMode.value ? '保存中...' : '创建中...')
|
||||
}
|
||||
return legacyT(isEditMode.value ? '保存' : '创建')
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
name: '',
|
||||
@@ -494,24 +503,24 @@ watch(() => form.value.provider_type, () => {
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
|
||||
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
|
||||
showError(legacyT('ClaudeCode 提供商类型暂时禁用'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError('月卡类型必须设置周期开始时间', '验证失败')
|
||||
showError(legacyT('月卡类型必须设置周期开始时间'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
|
||||
const quotaLastResetAt = dateTimeLocalToRfc3339(form.value.quota_last_reset_at)
|
||||
if (form.value.billing_type === 'monthly_quota' && !quotaLastResetAt) {
|
||||
showError('周期开始时间必须是合法时间', '验证失败')
|
||||
showError(legacyT('周期开始时间必须是合法时间'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
const quotaExpiresAt = dateTimeLocalToRfc3339(form.value.quota_expires_at)
|
||||
if (form.value.quota_expires_at && !quotaExpiresAt) {
|
||||
showError('过期时间必须是合法时间', '验证失败')
|
||||
showError(legacyT('过期时间必须是合法时间'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -555,19 +564,19 @@ const handleSubmit = async () => {
|
||||
...basePayload,
|
||||
provider_priority: form.value.provider_priority,
|
||||
})
|
||||
success('提供商更新成功')
|
||||
success(legacyT('提供商更新成功'))
|
||||
emit('providerUpdated', updated)
|
||||
} else {
|
||||
// 创建提供商(优先级由后端自动置顶)
|
||||
await createProvider(basePayload)
|
||||
success('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序', '创建成功')
|
||||
success(legacyT('提供商已创建,请继续添加端点和密钥,或在优先级管理中调整顺序'), legacyT('创建成功'))
|
||||
emit('providerCreated')
|
||||
}
|
||||
|
||||
emit('update:modelValue', false)
|
||||
} catch (error: unknown) {
|
||||
const action = isEditMode.value ? '更新' : '创建'
|
||||
showError(parseApiError(error, `${action}提供商失败`), `${action}失败`)
|
||||
showError(parseApiError(error, legacyT(`${action}提供商失败`)), legacyT(`${action}失败`))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '停用' }}
|
||||
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 内联编辑备注 (移动端) -->
|
||||
@@ -37,19 +37,19 @@
|
||||
v-model="localDescriptionValue"
|
||||
v-auto-focus
|
||||
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
placeholder="输入备注..."
|
||||
:placeholder="legacyT('输入备注...')"
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
:title="legacyT('保存')"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="取消"
|
||||
:title="legacyT('取消')"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
@@ -65,7 +65,7 @@
|
||||
v-else
|
||||
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
@click="handleStartEdit"
|
||||
>添加备注</span>
|
||||
>{{ legacyT('添加备注') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 shrink-0"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="查看详情"
|
||||
:title="legacyT('查看详情')"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
@@ -84,7 +84,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
:title="legacyT('编辑')"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
@@ -93,7 +93,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="扩展操作配置"
|
||||
:title="legacyT('扩展操作配置')"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
@@ -131,31 +131,31 @@
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
{{ legacyT('加载中...') }}
|
||||
</span>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
{{ legacyT('余额') }} <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
<!-- Cookie 失效警告 -->
|
||||
<span
|
||||
v-if="getProviderCookieExpired(provider.id)"
|
||||
class="ml-1 text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
>{{ legacyT('签到 Cookie 已失效') }}</span>
|
||||
<!-- 签到状态显示 -->
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id) && getProviderCheckin(provider.id)?.success !== false"
|
||||
class="ml-1 text-muted-foreground"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
>{{ legacyT('已签到') }}</span>
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id)?.success === false"
|
||||
class="ml-1 text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
>{{ legacyT('签到失败') }}</span>
|
||||
</span>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<span
|
||||
@@ -170,19 +170,19 @@
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
配额 <span
|
||||
{{ legacyT('配额') }} <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
|
||||
{{ legacyT('端点') }} {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ getCredentialLabel(provider) }} {{ provider.active_keys }}/{{ provider.total_keys }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
模型 {{ provider.active_models }}/{{ provider.total_models }}
|
||||
{{ legacyT('模型') }} {{ provider.active_models }}/{{ provider.total_models }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="flex flex-col gap-1.5"
|
||||
:title="getEndpointTooltip(endpoint)"
|
||||
:title="getEndpointTooltip(endpoint, locale)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
@@ -240,6 +240,7 @@ import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/e
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -270,6 +271,7 @@ const vAutoFocus = {
|
||||
}
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
watch(
|
||||
() => props.editingDescriptionId,
|
||||
@@ -304,7 +306,7 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
|
||||
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
|
||||
function getCredentialLabel(provider: ProviderWithEndpointsSummary): string {
|
||||
return legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-sm sm:text-base font-semibold text-foreground shrink-0">
|
||||
提供商管理
|
||||
{{ legacyT('提供商管理') }}
|
||||
</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
@@ -15,7 +15,7 @@
|
||||
id="provider-search"
|
||||
:model-value="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索提供商..."
|
||||
:placeholder="legacyT('搜索提供商...')"
|
||||
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
@@ -28,7 +28,7 @@
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
<SelectValue :placeholder="legacyT('全部状态')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -36,7 +36,7 @@
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
{{ legacyT(status.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -49,7 +49,7 @@
|
||||
@update:model-value="$emit('update:filterApiFormat', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部格式" />
|
||||
<SelectValue :placeholder="legacyT('全部格式')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -57,7 +57,7 @@
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
{{ legacyT(fmt.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -70,7 +70,7 @@
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
<SelectValue :placeholder="legacyT('全部模型')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -78,7 +78,7 @@
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
{{ legacyT(model.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -90,7 +90,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置筛选"
|
||||
:title="legacyT('重置筛选')"
|
||||
@click="$emit('resetFilters')"
|
||||
>
|
||||
<FilterX class="w-3.5 h-3.5" />
|
||||
@@ -101,10 +101,10 @@
|
||||
<!-- 调度策略 -->
|
||||
<button
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
title="点击调整调度策略"
|
||||
:title="legacyT('点击调整调度策略')"
|
||||
@click="$emit('openPriorityDialog')"
|
||||
>
|
||||
<span class="text-muted-foreground/80 hidden sm:inline">调度:</span>
|
||||
<span class="text-muted-foreground/80 hidden sm:inline">{{ legacyT('调度:') }}</span>
|
||||
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
@@ -116,7 +116,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="批量处理提供商"
|
||||
:title="legacyT('批量处理提供商')"
|
||||
:disabled="loading"
|
||||
@click="$emit('batchProcess')"
|
||||
>
|
||||
@@ -126,7 +126,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="新增提供商"
|
||||
:title="legacyT('新增提供商')"
|
||||
@click="$emit('addProvider')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
@@ -151,6 +151,7 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import type { FilterOption } from '@/features/providers/composables/useProviderFilters'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
searchQuery: string
|
||||
@@ -176,4 +177,6 @@ defineEmits<{
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
|
||||
@@ -31,19 +31,19 @@
|
||||
v-model="localDescriptionValue"
|
||||
v-auto-focus
|
||||
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
placeholder="输入备注..."
|
||||
:placeholder="legacyT('输入备注...')"
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
:title="legacyT('保存')"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="取消"
|
||||
:title="legacyT('取消')"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
@@ -59,7 +59,7 @@
|
||||
v-else
|
||||
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
|
||||
@click="handleStartEdit"
|
||||
>添加备注</span>
|
||||
>{{ legacyT('添加备注') }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
@@ -79,7 +79,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<div class="inline-grid grid-cols-[1.75rem_1.75rem_1.75rem] gap-x-0.5 gap-y-0.5 text-xs text-left">
|
||||
<span class="text-muted-foreground/70">端点:</span>
|
||||
<span class="text-muted-foreground/70">{{ legacyT('端点:') }}</span>
|
||||
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_endpoints }}</span>
|
||||
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_endpoints }}</span>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_keys }}</span>
|
||||
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_keys }}</span>
|
||||
|
||||
<span class="text-muted-foreground/70">模型:</span>
|
||||
<span class="text-muted-foreground/70">{{ legacyT('模型:') }}</span>
|
||||
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_models }}</span>
|
||||
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_models }}</span>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="flex flex-col gap-1.5"
|
||||
:title="getEndpointTooltip(endpoint)"
|
||||
:title="getEndpointTooltip(endpoint, locale)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
@@ -126,14 +126,14 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>暂无端点</span>
|
||||
>{{ legacyT('暂无端点') }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '停用' }}
|
||||
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
@@ -145,7 +145,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="查看详情"
|
||||
:title="legacyT('查看详情')"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
@@ -154,7 +154,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="编辑提供商"
|
||||
:title="legacyT('编辑提供商')"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
@@ -163,7 +163,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="扩展操作配置"
|
||||
:title="legacyT('扩展操作配置')"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
@@ -172,7 +172,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
:title="provider.is_active ? '停用提供商' : '启用提供商'"
|
||||
:title="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
@@ -181,7 +181,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
title="删除提供商"
|
||||
:title="legacyT('删除提供商')"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
@@ -212,6 +212,7 @@ import ProviderBalanceCell from './ProviderBalanceCell.vue'
|
||||
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -247,6 +248,7 @@ const vAutoFocus = {
|
||||
}
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
// 当进入编辑模式时,同步 props 的 description
|
||||
watch(
|
||||
@@ -282,8 +284,8 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
|
||||
function getCredentialLabel(provider: ProviderWithEndpointsSummary): string {
|
||||
const providerType = String(provider.provider_type || '').trim().toLowerCase()
|
||||
return providerType && providerType !== 'custom' ? '账号' : '密钥'
|
||||
return legacyT(providerType && providerType !== 'custom' ? '账号' : '密钥')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<SelectTrigger :class="triggerClass">
|
||||
<SelectValue
|
||||
:placeholder="proxyNodesStore.loading
|
||||
? '加载节点列表中...'
|
||||
? legacyT('加载节点列表中...')
|
||||
: nodeOptions.length === 0
|
||||
? '暂无可用节点'
|
||||
: '选择代理节点...'"
|
||||
? legacyT('暂无可用节点')
|
||||
: legacyT('选择代理节点...')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -49,6 +50,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
/** 在线节点 + 保留当前已选节点(可能已离线) */
|
||||
const nodeOptions = computed(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EndpointHealthDetail } from '@/api/endpoints'
|
||||
import { defaultLocale, translateLegacyText, type Locale } from '@/i18n/messages'
|
||||
|
||||
// 端点状态枚举
|
||||
export type EndpointStatus = 'disabled' | 'no_keys' | 'keys_disabled' | 'available'
|
||||
@@ -75,23 +76,24 @@ export function getEndpointDotColor(endpoint: EndpointHealthDetail): string {
|
||||
/**
|
||||
* 端点提示文本
|
||||
*/
|
||||
export function getEndpointTooltip(endpoint: EndpointHealthDetail): string {
|
||||
export function getEndpointTooltip(endpoint: EndpointHealthDetail, locale: Locale = defaultLocale): string {
|
||||
const format = endpoint.api_format
|
||||
const status = getEndpointStatus(endpoint)
|
||||
const t = (value: string) => translateLegacyText(value, locale)
|
||||
|
||||
switch (status) {
|
||||
case 'disabled':
|
||||
return `${format}: 端点禁用`
|
||||
return `${format}: ${t('端点禁用')}`
|
||||
case 'no_keys':
|
||||
return `${format}: 未配置密钥`
|
||||
return `${format}: ${t('未配置密钥')}`
|
||||
case 'keys_disabled':
|
||||
return `${format}: 无可用密钥`
|
||||
return `${format}: ${t('无可用密钥')}`
|
||||
case 'available': {
|
||||
const score = endpoint.health_score
|
||||
if (score === undefined || score === null) {
|
||||
return `${format}: 暂无健康数据`
|
||||
return `${format}: ${t('暂无健康数据')}`
|
||||
}
|
||||
return `${format}: 健康度 ${(score * 100).toFixed(0)}%`
|
||||
return `${format}: ${t('健康度')} ${(score * 100).toFixed(0)}%`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ProviderSummaryQuery } from '@/api/endpoints'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface FilterOption {
|
||||
value: string
|
||||
@@ -9,20 +10,21 @@ export interface FilterOption {
|
||||
export function useProviderFilters(
|
||||
globalModels: () => { id: string; name: string }[],
|
||||
) {
|
||||
const { legacyT } = useI18n()
|
||||
// 搜索与筛选
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref('all')
|
||||
const filterApiFormat = ref('all')
|
||||
const filterModel = ref('all')
|
||||
|
||||
const statusFilters: FilterOption[] = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '活跃' },
|
||||
{ value: 'inactive', label: '停用' },
|
||||
]
|
||||
const statusFilters = computed<FilterOption[]>(() => [
|
||||
{ value: 'all', label: legacyT('全部状态') },
|
||||
{ value: 'active', label: legacyT('活跃') },
|
||||
{ value: 'inactive', label: legacyT('停用') },
|
||||
])
|
||||
|
||||
const apiFormatFilters: FilterOption[] = [
|
||||
{ value: 'all', label: '全部格式' },
|
||||
const apiFormatFilters = computed<FilterOption[]>(() => [
|
||||
{ value: 'all', label: legacyT('全部格式') },
|
||||
{ value: 'claude:messages', label: 'Claude Messages' },
|
||||
{ value: 'openai:chat', label: 'OpenAI Chat' },
|
||||
{ value: 'openai:responses', label: 'OpenAI Responses' },
|
||||
@@ -35,13 +37,13 @@ export function useProviderFilters(
|
||||
{ value: 'jina:rerank', label: 'Jina Rerank' },
|
||||
{ value: 'doubao:embedding', label: 'Doubao Embedding' },
|
||||
{ value: 'aliyun:multimodal_embedding', label: 'Aliyun Multimodal Embedding' },
|
||||
]
|
||||
])
|
||||
|
||||
const modelFilters = computed<FilterOption[]>(() => {
|
||||
const items = globalModels()
|
||||
.map(m => ({ value: m.id, label: m.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return [{ value: 'all', label: '全部模型' }, ...items]
|
||||
return [{ value: 'all', label: legacyT('全部模型') }, ...items]
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
size="lg"
|
||||
@update:model-value="(value) => !value && $emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
||||
{{ legacyT('创建成功') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('请妥善保管, 切勿泄露给他人.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">API Key</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:model-value="apiKey"
|
||||
readonly
|
||||
class="h-11 flex-1 bg-muted/50 font-mono text-sm"
|
||||
@click="selectApiKey"
|
||||
/>
|
||||
<Button
|
||||
class="h-11"
|
||||
@click="$emit('copy')"
|
||||
>
|
||||
{{ legacyT('复制') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ legacyT('确定') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle } from 'lucide-vue-next'
|
||||
import { Button, Dialog, Input, Label } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
apiKey: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
copy: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function selectApiKey(event: MouseEvent) {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
target?.select?.()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div :class="mobile ? 'grid grid-cols-2 gap-2 pt-0.5' : 'flex justify-center gap-1'">
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
:title="legacyT('编辑用户')"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<SquarePen :class="iconClass" />
|
||||
<span v-if="mobile">{{ legacyT('编辑') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
:title="legacyT('资金操作')"
|
||||
@click="$emit('wallet')"
|
||||
>
|
||||
<DollarSign :class="iconClass" />
|
||||
<span v-if="mobile">{{ legacyT('资金') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
:title="legacyT('套餐')"
|
||||
@click="$emit('plans')"
|
||||
>
|
||||
<PackageCheck :class="iconClass" />
|
||||
<span v-if="mobile">{{ legacyT('套餐') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
title="API Keys"
|
||||
@click="$emit('api-keys')"
|
||||
>
|
||||
<Key :class="iconClass" />
|
||||
<span v-if="mobile">API Keys</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
:title="legacyT('登录设备')"
|
||||
@click="$emit('sessions')"
|
||||
>
|
||||
<MonitorSmartphone :class="iconClass" />
|
||||
<span v-if="mobile">{{ legacyT('设备') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'h-8 text-xs' : 'h-8 w-8'"
|
||||
:title="isActive ? legacyT('禁用用户') : legacyT('启用用户')"
|
||||
@click="$emit('toggle-status')"
|
||||
>
|
||||
<PauseCircle
|
||||
v-if="isActive"
|
||||
:class="iconClass"
|
||||
/>
|
||||
<PlayCircle
|
||||
v-else
|
||||
:class="iconClass"
|
||||
/>
|
||||
<span v-if="mobile">{{ legacyT(isActive ? '禁用' : '启用') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
:variant="mobile ? 'outline' : 'ghost'"
|
||||
:size="mobile ? 'sm' : 'icon'"
|
||||
:class="mobile ? 'col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40' : 'h-8 w-8'"
|
||||
:title="legacyT('删除用户')"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<Trash2 :class="iconClass" />
|
||||
<span v-if="mobile">{{ legacyT('删除') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
DollarSign,
|
||||
Key,
|
||||
MonitorSmartphone,
|
||||
PackageCheck,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
canOperateAdmin: boolean
|
||||
isActive: boolean
|
||||
mobile?: boolean
|
||||
}>(), {
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
edit: []
|
||||
wallet: []
|
||||
plans: []
|
||||
'api-keys': []
|
||||
sessions: []
|
||||
'toggle-status': []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const iconClass = computed(() => props.mobile ? 'mr-1.5 h-3.5 w-3.5' : 'h-4 w-4')
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
size="lg"
|
||||
@update:model-value="(value) => !value && $emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
|
||||
<Key class="h-5 w-5 text-kraft" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
||||
{{ isEditing ? legacyT('编辑 API Key') : legacyT('创建 API Key') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditing ? legacyT('更新用户 API Key 的名称、速率限制和并发限制') : legacyT('为用户创建新的 API Key') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="admin-user-key-name"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ legacyT('密钥名称') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-key-name"
|
||||
:model-value="form.name"
|
||||
class="h-10"
|
||||
:placeholder="legacyT('例如:生产环境 Key')"
|
||||
@update:model-value="updateField('name', String($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="admin-user-key-rate-limit"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ legacyT('速率限制 (请求/分钟)') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-key-rate-limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-10"
|
||||
:placeholder="legacyT('留空不限')"
|
||||
@update:model-value="updateField('rate_limit', parseNumberInput($event, { min: 0, max: 10000 }))"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('留空表示不限制') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="admin-user-key-concurrent-limit"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ legacyT('并发限制') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-key-concurrent-limit"
|
||||
:model-value="form.concurrent_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-10"
|
||||
placeholder="0 = unlimited"
|
||||
@update:model-value="updateField('concurrent_limit', parseNumberInput($event, { min: 0, max: 10000 }))"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT(isEditing ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="admin-user-key-ip-rules"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ legacyT('IP 限制') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-key-ip-rules"
|
||||
:model-value="form.ip_rules_text"
|
||||
class="h-10"
|
||||
placeholder="203.0.113.10, 10.0.0.0/24, !10.0.0.13"
|
||||
@update:model-value="updateField('ip_rules_text', String($event))"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('留空表示不限制;支持 IP、CIDR、IPv4 通配符、*,用 ! 前缀拒绝,多个规则用英文逗号分隔') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">
|
||||
{{ legacyT('敏感信息保护') }}
|
||||
</Label>
|
||||
<Switch
|
||||
:model-value="form.chat_pii_redaction_enabled"
|
||||
@update:model-value="updateField('chat_pii_redaction_enabled', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">
|
||||
{{ legacyT('占位符说明') }}
|
||||
</Label>
|
||||
<Switch
|
||||
:model-value="form.chat_pii_redaction_placeholder_notice"
|
||||
:disabled="!form.chat_pii_redaction_enabled"
|
||||
@update:model-value="updateField('chat_pii_redaction_placeholder_notice', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
:disabled="creating"
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Key } from 'lucide-vue-next'
|
||||
import { Button, Dialog, Input, Label, Switch } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
|
||||
export interface UserApiKeyFormState {
|
||||
name: string
|
||||
rate_limit?: number
|
||||
concurrent_limit?: number
|
||||
ip_rules_text: string
|
||||
chat_pii_redaction_enabled: boolean
|
||||
chat_pii_redaction_placeholder_notice: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
form: UserApiKeyFormState
|
||||
isEditing: boolean
|
||||
creating: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: []
|
||||
'update:form': [value: UserApiKeyFormState]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (props.creating) {
|
||||
return legacyT(props.isEditing ? '保存中...' : '创建中...')
|
||||
}
|
||||
return legacyT(props.isEditing ? '保存' : '创建')
|
||||
})
|
||||
|
||||
function updateField<TKey extends keyof UserApiKeyFormState>(key: TKey, value: UserApiKeyFormState[TKey]): void {
|
||||
emit('update:form', {
|
||||
...props.form,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
size="xl"
|
||||
@update:model-value="(value) => !value && $emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
|
||||
<Key class="h-5 w-5 text-kraft" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
||||
{{ legacyT('管理 API Keys') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('查看和管理用户的 API 密钥') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-h-[60vh] space-y-3 overflow-y-auto">
|
||||
<template v-if="apiKeys.length > 0">
|
||||
<div
|
||||
v-for="apiKey in apiKeys"
|
||||
:key="apiKey.id"
|
||||
class="rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/30"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-foreground">
|
||||
{{ apiKey.name || legacyT('未命名 API Key') }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ legacyT(apiKey.is_active ? '活跃' : '禁用') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.is_locked"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ legacyT('已锁定') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.is_standalone"
|
||||
variant="default"
|
||||
class="bg-purple-500 text-xs"
|
||||
>
|
||||
{{ legacyT('独立余额') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ legacyT(formatRateLimit(apiKey.rate_limit)) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ legacyT(formatConcurrentLimit(apiKey.concurrent_limit)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1">
|
||||
<code class="font-mono text-xs text-muted-foreground">
|
||||
{{ apiKey.key_display || '****' }}
|
||||
</code>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ legacyT('IP 限制:') }}{{ legacyT(formatIpRules(apiKey.ip_rules)) }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 transition-colors hover:bg-muted"
|
||||
:title="legacyT('复制完整密钥')"
|
||||
@click="$emit('copy-full-key', apiKey)"
|
||||
>
|
||||
<Copy class="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-4">
|
||||
<div class="text-right text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
{{ (apiKey.total_requests || 0).toLocaleString() }} {{ legacyT('次') }}
|
||||
</div>
|
||||
<div class="font-semibold text-rose-600">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('编辑')"
|
||||
@click="$emit('edit-key', apiKey)"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT(apiKey.is_locked ? '解锁' : '锁定')"
|
||||
@click="$emit('toggle-lock', apiKey)"
|
||||
>
|
||||
<Lock
|
||||
v-if="apiKey.is_locked"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<LockOpen
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('删除')"
|
||||
@click="$emit('delete-key', apiKey)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/20 px-4 py-12 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<Key class="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-base font-semibold text-foreground">
|
||||
{{ legacyT('暂无 API Keys') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ legacyT('点击下方按钮创建') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
:disabled="creating"
|
||||
@click="$emit('create-key')"
|
||||
>
|
||||
{{ creating ? legacyT('创建中...') : legacyT('创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Copy, Key, Lock, LockOpen, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import { Badge, Button, Dialog } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ApiKey } from '@/api/users'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
apiKeys: ApiKey[]
|
||||
creating: boolean
|
||||
formatRateLimit: (rateLimit?: number | null) => string
|
||||
formatConcurrentLimit: (concurrentLimit?: number | null) => string
|
||||
formatIpRules: (ipRules?: string[] | null) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
'create-key': []
|
||||
'edit-key': [apiKey: ApiKey]
|
||||
'toggle-lock': [apiKey: ApiKey]
|
||||
'delete-key': [apiKey: ApiKey]
|
||||
'copy-full-key': [apiKey: ApiKey]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">{{ legacyT('选择批量动作') }}</Label>
|
||||
<span class="text-[11px] text-muted-foreground">{{ legacyT('只会提交当前动作对应的字段') }}</span>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-4">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.value"
|
||||
type="button"
|
||||
:class="actionCardClass(action.value)"
|
||||
@click="$emit('update:modelValue', action.value)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span :class="actionIconClass(action.value)">
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
<span class="font-medium text-foreground">{{ legacyT(action.label) }}</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] leading-relaxed text-muted-foreground">
|
||||
{{ legacyT(action.description) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Label } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserBatchAction } from '@/api/users'
|
||||
import type { UserBatchActionOption } from './user-management-config'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: UserBatchAction
|
||||
actions: UserBatchActionOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: UserBatchAction]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function actionCardClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-primary/5 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/30',
|
||||
props.modelValue === action
|
||||
? 'border-primary/60 bg-primary/10 shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-border/70 bg-background',
|
||||
)
|
||||
}
|
||||
|
||||
function actionIconClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-lg transition-colors',
|
||||
props.modelValue === action
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,207 +1,49 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="用户批量操作"
|
||||
description="按当前选择批量调整用户状态、角色和额度"
|
||||
:title="legacyT('用户批量操作')"
|
||||
:description="legacyT('按当前选择批量调整用户状态、角色和额度')"
|
||||
size="2xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/10 via-background to-muted/40 p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<UsersRound class="h-4 w-4 text-primary" />
|
||||
<span>影响用户:{{ impactCount }} 个</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ selectAllFiltered ? '全选筛选结果' : '手动选择' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewLoading"
|
||||
class="mt-3 rounded-xl border border-border/60 bg-background/65 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
正在解析影响范围...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="previewItems.length > 0"
|
||||
class="mt-3 flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in previewItems"
|
||||
:key="item.user_id"
|
||||
variant="outline"
|
||||
class="bg-background/70 text-[11px]"
|
||||
>
|
||||
{{ item.username }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="impactCount > previewItems.length"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
等 {{ impactCount }} 个用户
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<UserBatchTargetSummary
|
||||
:select-all-filtered="selectAllFiltered"
|
||||
:impact-label="impactLabel"
|
||||
:impact-count="impactCount"
|
||||
:overflow-preview-label="overflowPreviewLabel"
|
||||
:loading="previewLoading"
|
||||
:preview-items="previewItems"
|
||||
/>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<div class="grid gap-2 rounded-xl border border-border/70 bg-muted/20 p-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">按分组选择</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
可与直接用户或筛选条件混合
|
||||
</p>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="selectedGroupIds"
|
||||
:options="groupOptions"
|
||||
:search-threshold="0"
|
||||
placeholder="选择一个或多个分组"
|
||||
empty-text="暂无用户分组"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">选择批量动作</Label>
|
||||
<span class="text-[11px] text-muted-foreground">只会提交当前动作对应的字段</span>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-4">
|
||||
<button
|
||||
v-for="action in actionOptions"
|
||||
:key="action.value"
|
||||
type="button"
|
||||
:class="actionCardClass(action.value)"
|
||||
@click="selectedAction = action.value"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span :class="actionIconClass(action.value)">
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
<span class="font-medium text-foreground">{{ action.label }}</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] leading-relaxed text-muted-foreground">
|
||||
{{ action.description }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<UserBatchGroupPicker
|
||||
v-model="selectedGroupIds"
|
||||
:groups="groups"
|
||||
/>
|
||||
<UserBatchActionCards
|
||||
v-model="selectedAction"
|
||||
:actions="USER_BATCH_ACTION_OPTIONS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<UserBatchRolePanel
|
||||
v-if="selectedAction === 'update_role'"
|
||||
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<UserCog class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
批量修改用户角色
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
将所选用户统一调整为同一个角色。管理员角色拥有后台管理权限,请确认选择范围。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
v-model="targetRole"
|
||||
:warning-text="targetRoleWarning"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">目标角色</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
对所有目标用户生效
|
||||
</p>
|
||||
</div>
|
||||
<Select v-model="targetRole">
|
||||
<SelectTrigger class="h-10 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">
|
||||
普通用户
|
||||
</SelectItem>
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户完整后台管理能力。' : targetRole === 'audit_admin' ? '提示:设置为审计管理员会授予后台只读查看能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<UserBatchQuotaPanel
|
||||
v-if="selectedAction === 'update_access_control'"
|
||||
class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
批量设置额度
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
额度仍然属于用户账户属性;模型、端点、提供商和限速请通过用户组管理。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
v-model="quotaMode"
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">额度</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
对所有目标用户生效
|
||||
</p>
|
||||
</div>
|
||||
<Select v-model="quotaMode">
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
不修改
|
||||
</SelectItem>
|
||||
<SelectItem value="wallet">
|
||||
按钱包余额限制
|
||||
</SelectItem>
|
||||
<SelectItem value="unlimited">
|
||||
无限额度
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lastResult"
|
||||
class="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
成功 {{ lastResult.success }} 个,失败 {{ lastResult.failed }} 个
|
||||
<span v-if="lastResult.failures.length > 0">
|
||||
:{{ lastResult.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(';') }}
|
||||
</span>
|
||||
</div>
|
||||
<UserBatchResultSummary
|
||||
:result="lastResult"
|
||||
:label="lastResultLabel"
|
||||
:failures-label="lastResultFailuresLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -210,43 +52,36 @@
|
||||
:disabled="executing"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canExecute"
|
||||
@click="executeBatchAction"
|
||||
>
|
||||
{{ executing ? '执行中...' : executeButtonLabel }}
|
||||
{{ executing ? legacyT('执行中...') : executeButtonLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
UsersRound,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Badge,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import UserBatchActionCards from './UserBatchActionCards.vue'
|
||||
import UserBatchGroupPicker from './UserBatchGroupPicker.vue'
|
||||
import UserBatchQuotaPanel from './UserBatchQuotaPanel.vue'
|
||||
import UserBatchResultSummary from './UserBatchResultSummary.vue'
|
||||
import UserBatchRolePanel from './UserBatchRolePanel.vue'
|
||||
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
|
||||
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
|
||||
import type { UserBatchQuotaMode } from './user-management-types'
|
||||
import type {
|
||||
UserBatchAccessControlPayload,
|
||||
UserBatchAction,
|
||||
@@ -260,15 +95,6 @@ import type {
|
||||
UserGroup,
|
||||
} from '@/api/users'
|
||||
|
||||
type QuotaMode = 'skip' | 'wallet' | 'unlimited'
|
||||
|
||||
interface ActionOption {
|
||||
value: UserBatchAction
|
||||
label: string
|
||||
description: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
selectedIds: string[]
|
||||
@@ -285,37 +111,11 @@ const emit = defineEmits<{
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const { success, warning, error } = useToast()
|
||||
|
||||
const actionOptions: ActionOption[] = [
|
||||
{
|
||||
value: 'enable',
|
||||
label: '启用',
|
||||
description: '恢复用户登录与调用',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
value: 'disable',
|
||||
label: '禁用',
|
||||
description: '暂停用户访问权限',
|
||||
icon: Ban,
|
||||
},
|
||||
{
|
||||
value: 'update_access_control',
|
||||
label: '额度',
|
||||
description: '批量调整用户额度模式',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
value: 'update_role',
|
||||
label: '修改角色',
|
||||
description: '批量设为普通用户或管理员',
|
||||
icon: UserCog,
|
||||
},
|
||||
]
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const selectedAction = ref<UserBatchAction>('enable')
|
||||
const targetRole = ref<UserRole>('user')
|
||||
const quotaMode = ref<QuotaMode>('skip')
|
||||
const quotaMode = ref<UserBatchQuotaMode>('skip')
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
const previewLoading = ref(false)
|
||||
const previewItems = ref<UserBatchSelectionItem[]>([])
|
||||
@@ -323,17 +123,35 @@ const resolvedTotal = ref<number | null>(null)
|
||||
const executing = ref(false)
|
||||
const lastResult = ref<UserBatchActionResponse | null>(null)
|
||||
|
||||
const groupOptions = computed(() => props.groups.map((group) => ({
|
||||
label: `${group.name}${group.is_default ? '(默认)' : ''}`,
|
||||
value: group.id,
|
||||
})))
|
||||
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
|
||||
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
||||
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
|
||||
const selectedActionLabel = computed(() => (
|
||||
actionOptions.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||
))
|
||||
const executeButtonLabel = computed(() => `确认${selectedActionLabel.value}(${impactCount.value})`)
|
||||
const impactLabel = computed(() => locale.value === 'en-US'
|
||||
? `Affected users: ${impactCount.value}`
|
||||
: `影响用户:${impactCount.value} 个`)
|
||||
const overflowPreviewLabel = computed(() => legacyT(`等 ${impactCount.value} 个用户`))
|
||||
const targetRoleWarning = computed(() => {
|
||||
if (targetRole.value === 'admin') {
|
||||
return legacyT('提示:设置为管理员会授予用户完整后台管理能力。')
|
||||
}
|
||||
if (targetRole.value === 'audit_admin') {
|
||||
return legacyT('提示:设置为审计管理员会授予后台只读查看能力。')
|
||||
}
|
||||
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
|
||||
})
|
||||
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
|
||||
const lastResultLabel = computed(() => {
|
||||
if (!lastResult.value) return ''
|
||||
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
|
||||
})
|
||||
const lastResultFailuresLabel = computed(() => {
|
||||
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
|
||||
const failures = lastResult.value.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(locale.value === 'en-US' ? '; ' : ';')
|
||||
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -363,24 +181,6 @@ function resetLocalState(): void {
|
||||
lastResult.value = null
|
||||
}
|
||||
|
||||
function actionCardClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-primary/35 hover:bg-primary/5 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/30',
|
||||
selectedAction.value === action
|
||||
? 'border-primary/60 bg-primary/10 shadow-sm ring-1 ring-primary/20'
|
||||
: 'border-border/70 bg-background',
|
||||
)
|
||||
}
|
||||
|
||||
function actionIconClass(action: UserBatchAction): string {
|
||||
return cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-lg transition-colors',
|
||||
selectedAction.value === action
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)
|
||||
}
|
||||
|
||||
function buildSelection(): UserBatchSelection {
|
||||
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
|
||||
if (props.selectAllFiltered) {
|
||||
@@ -403,7 +203,7 @@ async function resolvePreview(): Promise<void> {
|
||||
} catch (err) {
|
||||
resolvedTotal.value = props.selectedCount
|
||||
previewItems.value = []
|
||||
error(parseApiError(err, '解析用户选择失败'))
|
||||
error(parseApiError(err, '解析用户选择失败'), legacyT('解析用户选择失败'))
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
@@ -431,7 +231,7 @@ async function executeBatchAction(): Promise<void> {
|
||||
if (selectedAction.value === 'update_access_control') {
|
||||
const payload = buildAccessControlPayload()
|
||||
if (payload === null) {
|
||||
warning('请选择要修改的额度')
|
||||
warning(legacyT('请选择要修改的额度'))
|
||||
return
|
||||
}
|
||||
request = { selection, action: 'update_access_control', payload }
|
||||
@@ -445,7 +245,7 @@ async function executeBatchAction(): Promise<void> {
|
||||
try {
|
||||
const result = await usersStore.batchAction(request)
|
||||
lastResult.value = result
|
||||
const message = `批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`
|
||||
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||
if (result.failed > 0) {
|
||||
warning(message)
|
||||
} else {
|
||||
@@ -453,7 +253,7 @@ async function executeBatchAction(): Promise<void> {
|
||||
}
|
||||
emit('completed', result)
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '批量操作失败'))
|
||||
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="grid gap-2 rounded-xl border border-border/70 bg-muted/20 p-3 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-start">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">{{ legacyT('按分组选择') }}</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ legacyT('可与直接用户或筛选条件混合') }}
|
||||
</p>
|
||||
</div>
|
||||
<MultiSelect
|
||||
:model-value="modelValue"
|
||||
:options="groupOptions"
|
||||
:search-threshold="0"
|
||||
:placeholder="legacyT('选择一个或多个分组')"
|
||||
:empty-text="legacyT('暂无用户分组')"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Label } from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserGroup } from '@/api/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
groups: UserGroup[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const groupOptions = computed(() => props.groups.map((group) => ({
|
||||
label: `${group.name}${group.is_default ? ` (${legacyT('默认')})` : ''}`,
|
||||
value: group.id,
|
||||
})))
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
{{ legacyT('批量设置额度') }}
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ legacyT('额度仍然属于用户账户属性;模型、端点、提供商和限速请通过用户组管理。') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">{{ legacyT('额度') }}</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ legacyT('对所有目标用户生效') }}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
{{ legacyT('不修改') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="wallet">
|
||||
{{ legacyT('按钱包余额限制') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="unlimited">
|
||||
{{ legacyT('无限额度') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { ShieldCheck } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserBatchQuotaMode } from './user-management-types'
|
||||
|
||||
defineProps<{
|
||||
modelValue: UserBatchQuotaMode
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: UserBatchQuotaMode]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="result"
|
||||
class="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ label }}
|
||||
<span v-if="failuresLabel">
|
||||
{{ failuresLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserBatchActionResponse } from '@/api/users'
|
||||
|
||||
defineProps<{
|
||||
result: UserBatchActionResponse | null
|
||||
label: string
|
||||
failuresLabel: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="space-y-4 rounded-2xl border bg-background p-4 shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<UserCog class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
{{ legacyT('批量修改用户角色') }}
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ legacyT('将所选用户统一调整为同一个角色。管理员角色拥有后台管理权限,请确认选择范围。') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-xl border border-border/70 bg-muted/25 p-3 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">{{ legacyT('目标角色') }}</Label>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ legacyT('对所有目标用户生效') }}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<SelectTrigger class="h-10 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">
|
||||
{{ legacyT('普通用户') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="admin">
|
||||
{{ legacyT('管理员') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
{{ legacyT('审计管理员') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ warningText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { UserCog } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserRole } from '@/api/users'
|
||||
|
||||
defineProps<{
|
||||
modelValue: UserRole
|
||||
warningText: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: UserRole]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/10 via-background to-muted/40 p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<UsersRound class="h-4 w-4 text-primary" />
|
||||
<span>{{ impactLabel }}</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ legacyT(selectAllFiltered ? '目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。' : '目标为当前已勾选的用户,重复 ID 会自动去重。') }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ legacyT(selectAllFiltered ? '全选筛选结果' : '手动选择') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="mt-3 rounded-xl border border-border/60 bg-background/65 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('正在解析影响范围...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="previewItems.length > 0"
|
||||
class="mt-3 flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in previewItems"
|
||||
:key="item.user_id"
|
||||
variant="outline"
|
||||
class="bg-background/70 text-[11px]"
|
||||
>
|
||||
{{ item.username }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="impactCount > previewItems.length"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ overflowPreviewLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui'
|
||||
import { UsersRound } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserBatchSelectionItem } from '@/api/users'
|
||||
|
||||
defineProps<{
|
||||
selectAllFiltered: boolean
|
||||
impactLabel: string
|
||||
impactCount: number
|
||||
overflowPreviewLabel: string
|
||||
loading: boolean
|
||||
previewItems: UserBatchSelectionItem[]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div :class="mobile ? 'flex flex-wrap items-center gap-2' : 'flex items-center gap-2'">
|
||||
<div :class="mobile ? 'relative min-w-40 flex-1' : 'relative'">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
:id="mobile ? 'users-search-mobile' : 'users-search'"
|
||||
:model-value="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
:class="mobile ? 'w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60' : 'w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors'"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<div class="xl:hidden">
|
||||
<Select
|
||||
:model-value="filterRole"
|
||||
@update:model-value="$emit('update:filterRole', $event)"
|
||||
>
|
||||
<SelectTrigger :class="mobile ? 'w-24 h-8 text-xs border-border/60' : 'w-32 h-8 text-xs border-border/60'">
|
||||
<SelectValue :placeholder="legacyT(mobile ? '角色' : '全部角色')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ legacyT(option.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger :class="mobile ? 'w-20 h-8 text-xs border-border/60' : 'w-28 h-8 text-xs border-border/60'">
|
||||
<SelectValue :placeholder="legacyT(mobile ? '状态' : '全部状态')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in statusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ legacyT(option.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="filterGroup"
|
||||
@update:model-value="$emit('update:filterGroup', $event)"
|
||||
>
|
||||
<SelectTrigger :class="mobile ? 'w-24 h-8 text-xs border-border/60' : 'w-32 h-8 text-xs border-border/60'">
|
||||
<SelectValue :placeholder="legacyT(mobile ? '分组' : '全部分组')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{{ legacyT(mobile ? '全部' : '全部分组') }}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="group in userGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<Select
|
||||
:model-value="sortOption"
|
||||
@update:model-value="$emit('update:sortOption', $event)"
|
||||
>
|
||||
<SelectTrigger :class="mobile ? 'w-32 h-8 text-xs border-border/60' : 'w-40 h-8 text-xs border-border/60'">
|
||||
<SelectValue :placeholder="legacyT('排序')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ legacyT(option.label) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import type { UserGroup, UserRole } from '@/api/users'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
type FilterRole = 'all' | UserRole
|
||||
type FilterStatus = 'all' | 'active' | 'inactive'
|
||||
type SortOption = 'default' | 'created_at_desc' | 'created_at_asc'
|
||||
|
||||
interface FilterOption<TValue extends string = string> {
|
||||
value: TValue
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
searchQuery: string
|
||||
filterRole: FilterRole
|
||||
filterGroup: string
|
||||
filterStatus: FilterStatus
|
||||
sortOption: SortOption
|
||||
userGroups: UserGroup[]
|
||||
roleOptions: FilterOption<FilterRole>[]
|
||||
statusOptions: FilterOption<FilterStatus>[]
|
||||
sortOptions: FilterOption<SortOption>[]
|
||||
mobile?: boolean
|
||||
}>(), {
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:filterRole': [value: FilterRole]
|
||||
'update:filterGroup': [value: string]
|
||||
'update:filterStatus': [value: FilterStatus]
|
||||
'update:sortOption': [value: SortOption]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const searchPlaceholder = computed(() => props.mobile ? legacyT('搜索...') : legacyT('搜索用户名或邮箱...'))
|
||||
</script>
|
||||
@@ -21,10 +21,10 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||
{{ isEditMode ? '编辑用户' : '新增用户' }}
|
||||
{{ legacyT(isEditMode ? '编辑用户' : '新增用户') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ isEditMode ? '修改用户账户信息' : '创建新的系统用户账户' }}
|
||||
{{ legacyT(isEditMode ? '修改用户账户信息' : '创建新的系统用户账户') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<Label
|
||||
for="form-username"
|
||||
class="text-sm font-medium"
|
||||
>用户名 <span class="text-muted-foreground">*</span></Label>
|
||||
>{{ legacyT('用户名') }} <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="form-username"
|
||||
v-model="form.username"
|
||||
@@ -64,7 +64,7 @@
|
||||
<Label
|
||||
for="form-role"
|
||||
class="text-sm font-medium"
|
||||
>用户角色</Label>
|
||||
>{{ legacyT('用户角色') }}</Label>
|
||||
<div class="w-full">
|
||||
<Select v-model="form.role">
|
||||
<SelectTrigger
|
||||
@@ -75,13 +75,13 @@
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">
|
||||
普通用户
|
||||
{{ legacyT('普通用户') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
{{ legacyT('管理员') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
{{ legacyT('审计管理员') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -93,7 +93,7 @@
|
||||
<Label
|
||||
for="form-email"
|
||||
class="text-sm font-medium"
|
||||
>邮箱</Label>
|
||||
>{{ legacyT('邮箱') }}</Label>
|
||||
<Input
|
||||
id="form-email"
|
||||
v-model="form.email"
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">
|
||||
{{ isEditMode ? '新密码 (留空保持不变)' : '密码' }}
|
||||
{{ legacyT(isEditMode ? '新密码 (留空保持不变)' : '密码') }}
|
||||
<span
|
||||
v-if="!isEditMode"
|
||||
class="text-muted-foreground"
|
||||
@@ -122,7 +122,7 @@
|
||||
:name="`field-${formNonce}`"
|
||||
:required="!isEditMode"
|
||||
minlength="6"
|
||||
:placeholder="isEditMode ? '留空保持原密码' : getPasswordPolicyPlaceholder(passwordPolicyLevel)"
|
||||
:placeholder="isEditMode ? legacyT('留空保持原密码') : legacyT(getPasswordPolicyPlaceholder(passwordPolicyLevel))"
|
||||
class="h-10"
|
||||
:class="[
|
||||
passwordError ? 'border-destructive' : '',
|
||||
@@ -132,13 +132,13 @@
|
||||
v-if="passwordError"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ passwordError }}
|
||||
{{ legacyT(passwordError) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!isEditMode"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ passwordHint }}
|
||||
{{ legacyT(passwordHint) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label class="text-sm font-medium">
|
||||
确认新密码 <span class="text-muted-foreground">*</span>
|
||||
{{ legacyT('确认新密码') }} <span class="text-muted-foreground">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
@@ -160,7 +160,7 @@
|
||||
:name="`confirm-${formNonce}`"
|
||||
required
|
||||
minlength="6"
|
||||
placeholder="再次输入新密码"
|
||||
:placeholder="legacyT('再次输入新密码')"
|
||||
class="h-10"
|
||||
/>
|
||||
<p
|
||||
@@ -170,24 +170,24 @@
|
||||
"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
{{ legacyT('两次输入的密码不一致') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">所属分组</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('所属分组') }}</Label>
|
||||
<MultiSelect
|
||||
v-model="form.group_ids"
|
||||
:options="groupOptions"
|
||||
:search-threshold="0"
|
||||
placeholder="可选择多个分组"
|
||||
empty-text="暂无分组"
|
||||
no-results-text="未找到匹配的分组"
|
||||
:placeholder="legacyT('可选择多个分组')"
|
||||
:empty-text="legacyT('暂无分组')"
|
||||
:no-results-text="legacyT('未找到匹配的分组')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">额度</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('额度') }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Input
|
||||
@@ -197,14 +197,14 @@
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="初始额度 (USD)"
|
||||
:placeholder="legacyT('初始额度 (USD)')"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
|
||||
>{{ form.unlimited ? '无限制' : '按钱包余额限制' }}</span>
|
||||
>{{ legacyT(form.unlimited ? '无限制' : '按钱包余额限制') }}</span>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.unlimited"
|
||||
@@ -215,14 +215,14 @@
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div class="mb-3 text-xs font-semibold text-muted-foreground">
|
||||
功能权限
|
||||
{{ legacyT('功能权限') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('敏感信息保护') }}</Label>
|
||||
<Switch v-model="form.chat_pii_redaction_enabled" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">占位符说明</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('占位符说明') }}</Label>
|
||||
<Switch
|
||||
v-model="form.chat_pii_redaction_placeholder_notice"
|
||||
:disabled="!form.chat_pii_redaction_enabled"
|
||||
@@ -230,9 +230,9 @@
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">通知推送服务</Label>
|
||||
<Label class="text-sm font-medium">{{ legacyT('通知推送服务') }}</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
允许用户配置自己的第三方推送渠道
|
||||
{{ legacyT('允许用户配置自己的第三方推送渠道') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="form.notification_push_service_enabled" />
|
||||
@@ -248,14 +248,14 @@
|
||||
class="h-10 px-5"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
:disabled="saving || !isFormValid"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ saving ? '处理中...' : isEditMode ? '更新' : '创建' }}
|
||||
{{ legacyT(saving ? '处理中...' : isEditMode ? '更新' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -280,6 +280,7 @@ import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
@@ -344,6 +345,7 @@ const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
})))
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function createFieldNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
@@ -403,10 +405,10 @@ const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
||||
const usernameError = computed(() => {
|
||||
const username = form.value.username.trim()
|
||||
if (!username) return ''
|
||||
if (username.length < 3) return '用户名长度至少为3个字符'
|
||||
if (username.length > 30) return '用户名长度不能超过30个字符'
|
||||
if (username.length < 3) return legacyT('用户名长度至少为3个字符')
|
||||
if (username.length > 30) return legacyT('用户名长度不能超过30个字符')
|
||||
if (!usernameRegex.test(username))
|
||||
return '用户名只能包含字母、数字、下划线、连字符和点号'
|
||||
return legacyT('用户名只能包含字母、数字、下划线、连字符和点号')
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="space-y-4 border-t border-border/60 pt-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 border-b border-border/60 pb-2">
|
||||
<span class="text-sm font-medium">{{ legacyT('组权限') }}</span>
|
||||
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{{ legacyT('组权限叠加,Key 可再收窄') }}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/70 bg-muted/40 text-muted-foreground outline-none transition-colors hover:border-primary/50 hover:text-primary focus-visible:border-primary/60 focus-visible:text-primary"
|
||||
:title="helpText"
|
||||
:aria-label="legacyT('查看组权限合并规则')"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-72 text-xs leading-5">
|
||||
{{ helpText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('允许的提供商') }}</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_providers_mode === 'unrestricted'"
|
||||
@update:model-value="setProvidersUnrestricted"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
:model-value="form.allowed_providers"
|
||||
:options="providerOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_providers_mode === 'unrestricted'"
|
||||
:placeholder="legacyT(form.allowed_providers_mode === 'unrestricted' ? '不限制所有选项' : '选择提供商')"
|
||||
:empty-text="legacyT('暂无选项')"
|
||||
@update:model-value="(value) => updateForm({ allowed_providers: value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('允许的端点') }}</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
|
||||
@update:model-value="setApiFormatsUnrestricted"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
:model-value="form.allowed_api_formats"
|
||||
:options="apiFormatOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_api_formats_mode === 'unrestricted'"
|
||||
:placeholder="legacyT(form.allowed_api_formats_mode === 'unrestricted' ? '不限制所有选项' : '选择端点')"
|
||||
:empty-text="legacyT('暂无选项')"
|
||||
@update:model-value="(value) => updateForm({ allowed_api_formats: value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('允许的模型') }}</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_models_mode === 'unrestricted'"
|
||||
@update:model-value="setModelsUnrestricted"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
:model-value="form.allowed_models"
|
||||
:options="modelOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_models_mode === 'unrestricted'"
|
||||
:placeholder="legacyT(form.allowed_models_mode === 'unrestricted' ? '不限制所有选项' : '选择模型')"
|
||||
:empty-text="legacyT('暂无选项')"
|
||||
@update:model-value="(value) => updateForm({ allowed_models: value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('速率限制 (请求/分钟)') }}</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.rate_limit_mode === 'system'"
|
||||
@update:model-value="setSystemRateLimit"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Input
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-10"
|
||||
:disabled="form.rate_limit_mode === 'system'"
|
||||
:placeholder="legacyT(form.rate_limit_mode === 'system' ? '使用系统默认' : '0 = 不限速')"
|
||||
@update:model-value="updateRateLimit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Info } from 'lucide-vue-next'
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserGroupFormState, UserSelectOption } from './user-management-types'
|
||||
|
||||
const props = defineProps<{
|
||||
form: UserGroupFormState
|
||||
providerOptions: UserSelectOption[]
|
||||
apiFormatOptions: UserSelectOption[]
|
||||
modelOptions: UserSelectOption[]
|
||||
helpText: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:form': [value: UserGroupFormState]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function updateForm(patch: Partial<UserGroupFormState>): void {
|
||||
emit('update:form', { ...props.form, ...patch })
|
||||
}
|
||||
|
||||
function setProvidersUnrestricted(value: boolean): void {
|
||||
updateForm({ allowed_providers_mode: value ? 'unrestricted' : 'specific' })
|
||||
}
|
||||
|
||||
function setApiFormatsUnrestricted(value: boolean): void {
|
||||
updateForm({ allowed_api_formats_mode: value ? 'unrestricted' : 'specific' })
|
||||
}
|
||||
|
||||
function setModelsUnrestricted(value: boolean): void {
|
||||
updateForm({ allowed_models_mode: value ? 'unrestricted' : 'specific' })
|
||||
}
|
||||
|
||||
function setSystemRateLimit(value: boolean): void {
|
||||
updateForm({ rate_limit_mode: value ? 'system' : 'custom' })
|
||||
}
|
||||
|
||||
function updateRateLimit(value: string | number): void {
|
||||
updateForm({ rate_limit: parseNumberInput(value, { min: 0, max: 10000 }) })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h4 class="truncate text-base font-semibold text-foreground">
|
||||
{{ legacyT(editing ? '编辑分组' : '新建分组') }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT(isDefault ? '当前为所有用户的默认组' : '通过额外分组配置访问限制') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="editing"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="isDefault ? 'text-emerald-500 hover:text-emerald-500' : ''"
|
||||
:disabled="saving || isDefault"
|
||||
:title="legacyT(isDefault ? '默认注册组' : '设为默认注册组')"
|
||||
@click="$emit('setDefault')"
|
||||
>
|
||||
<BadgeCheck class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="saving || isDefault"
|
||||
:title="legacyT('删除分组')"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BadgeCheck, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
editing: boolean
|
||||
isDefault: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
setDefault: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">{{ legacyT('分组') }}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('新建分组')"
|
||||
@click="$emit('create')"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('正在加载...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('暂无分组') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-60 space-y-1.5 overflow-y-auto lg:max-h-none lg:overflow-visible"
|
||||
>
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
type="button"
|
||||
:class="groupButtonClass(group.id)"
|
||||
@click="$emit('select', group.id)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 text-left">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium">{{ group.name }}</span>
|
||||
<Badge
|
||||
v-if="group.is_default"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ legacyT('默认') }}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import { Badge, Button, Label } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserGroup } from '@/api/users'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
groups: UserGroup[]
|
||||
selectedGroupId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
create: []
|
||||
select: [groupId: string]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function groupButtonClass(groupId: string): string {
|
||||
return cn(
|
||||
'flex w-full items-center gap-2 rounded-lg border px-3 py-2 transition-colors',
|
||||
props.selectedGroupId === groupId
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:border-border hover:bg-background',
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('名称') }}</Label>
|
||||
<Input
|
||||
:model-value="name"
|
||||
class="h-10"
|
||||
:placeholder="legacyT('例如:生产团队')"
|
||||
@update:model-value="$emit('update:name', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ legacyT('成员') }}</Label>
|
||||
<MultiSelect
|
||||
:model-value="memberUserIds"
|
||||
:options="userOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="membersDisabled"
|
||||
:placeholder="legacyT('选择用户')"
|
||||
:empty-text="legacyT('暂无用户')"
|
||||
:no-results-text="legacyT('未找到匹配用户')"
|
||||
@update:model-value="$emit('update:memberUserIds', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input, Label } from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserSelectOption } from './user-management-types'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
memberUserIds: string[]
|
||||
userOptions: UserSelectOption[]
|
||||
membersDisabled: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:memberUserIds': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -1,245 +1,47 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="用户分组"
|
||||
description="管理用户组、默认注册组、成员和组级访问控制"
|
||||
:title="legacyT('用户分组')"
|
||||
:description="legacyT('管理用户组、默认注册组、成员和组级访问控制')"
|
||||
size="4xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="grid gap-4 lg:min-h-[560px] lg:grid-cols-[17rem_minmax(0,1fr)]">
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">分组</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="新建分组"
|
||||
@click="startCreate"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无分组
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-60 space-y-1.5 overflow-y-auto lg:max-h-none lg:overflow-visible"
|
||||
>
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
type="button"
|
||||
:class="groupButtonClass(group.id)"
|
||||
@click="selectGroup(group.id)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 text-left">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium">{{ group.name }}</span>
|
||||
<Badge
|
||||
v-if="group.is_default"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
默认
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UserGroupListPanel
|
||||
:loading="loading"
|
||||
:groups="groups"
|
||||
:selected-group-id="editingGroupId"
|
||||
@create="startCreate"
|
||||
@select="selectGroup"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 rounded-xl border border-border/70 bg-background p-3 sm:p-4">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h4 class="truncate text-base font-semibold text-foreground">
|
||||
{{ editingGroupId ? '编辑分组' : '新建分组' }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectedGroup?.is_default ? '当前为所有用户的默认组' : '通过额外分组配置访问限制' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="editingGroupId"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="selectedGroup?.is_default ? 'text-emerald-500 hover:text-emerald-500' : ''"
|
||||
:disabled="saving || selectedGroup?.is_default"
|
||||
:title="selectedGroup?.is_default ? '默认注册组' : '设为默认注册组'"
|
||||
@click="toggleDefault"
|
||||
>
|
||||
<BadgeCheck class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="saving || selectedGroup?.is_default"
|
||||
title="删除分组"
|
||||
@click="deleteSelectedGroup"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UserGroupEditorHeader
|
||||
:editing="Boolean(editingGroupId)"
|
||||
:is-default="Boolean(selectedGroup?.is_default)"
|
||||
:saving="saving"
|
||||
@set-default="toggleDefault"
|
||||
@delete="deleteSelectedGroup"
|
||||
/>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">名称</Label>
|
||||
<Input
|
||||
v-model="form.name"
|
||||
class="h-10"
|
||||
placeholder="例如:生产团队"
|
||||
/>
|
||||
</div>
|
||||
<UserGroupProfileFields
|
||||
:name="form.name"
|
||||
:member-user-ids="memberUserIds"
|
||||
:user-options="userOptions"
|
||||
:members-disabled="Boolean(selectedGroup?.is_default)"
|
||||
@update:name="form.name = $event"
|
||||
@update:member-user-ids="memberUserIds = $event"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">成员</Label>
|
||||
<MultiSelect
|
||||
v-model="memberUserIds"
|
||||
:options="userOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="selectedGroup?.is_default"
|
||||
placeholder="选择用户"
|
||||
empty-text="暂无用户"
|
||||
no-results-text="未找到匹配用户"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 border-t border-border/60 pt-5">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 pb-2 border-b border-border/60">
|
||||
<span class="text-sm font-medium">组权限</span>
|
||||
<span class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
组权限叠加,Key 可再收窄
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/70 bg-muted/40 text-muted-foreground outline-none transition-colors hover:border-primary/50 hover:text-primary focus-visible:border-primary/60 focus-visible:text-primary"
|
||||
:title="groupPolicyHelpText"
|
||||
aria-label="查看组权限合并规则"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-72 text-xs leading-5">
|
||||
{{ groupPolicyHelpText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的提供商</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_providers_mode === 'unrestricted'"
|
||||
@update:model-value="(v) => (form.allowed_providers_mode = v ? 'unrestricted' : 'specific')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_providers"
|
||||
:options="providerOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_providers_mode === 'unrestricted'"
|
||||
:placeholder="form.allowed_providers_mode === 'unrestricted' ? '不限制所有选项' : '选择提供商'"
|
||||
empty-text="暂无选项"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的端点</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_api_formats_mode === 'unrestricted'"
|
||||
@update:model-value="(v) => (form.allowed_api_formats_mode = v ? 'unrestricted' : 'specific')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_api_formats"
|
||||
:options="apiFormatOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_api_formats_mode === 'unrestricted'"
|
||||
:placeholder="form.allowed_api_formats_mode === 'unrestricted' ? '不限制所有选项' : '选择端点'"
|
||||
empty-text="暂无选项"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">允许的模型</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.allowed_models_mode === 'unrestricted'"
|
||||
@update:model-value="(v) => (form.allowed_models_mode = v ? 'unrestricted' : 'specific')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<MultiSelect
|
||||
v-model="form.allowed_models"
|
||||
:options="modelOptions"
|
||||
:search-threshold="0"
|
||||
:disabled="form.allowed_models_mode === 'unrestricted'"
|
||||
:placeholder="form.allowed_models_mode === 'unrestricted' ? '不限制所有选项' : '选择模型'"
|
||||
empty-text="暂无选项"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">速率限制 (请求/分钟)</Label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="flex w-full items-center sm:w-auto sm:shrink-0">
|
||||
<Switch
|
||||
:model-value="form.rate_limit_mode === 'system'"
|
||||
@update:model-value="(v) => (form.rate_limit_mode = v ? 'system' : 'custom')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Input
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
class="h-10"
|
||||
:disabled="form.rate_limit_mode === 'system'"
|
||||
:placeholder="form.rate_limit_mode === 'system' ? '使用系统默认' : '0 = 不限速'"
|
||||
@update:model-value="(value) => form.rate_limit = parseNumberInput(value, { min: 0, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserGroupAccessControlFields
|
||||
v-model:form="form"
|
||||
:provider-options="providerOptions"
|
||||
:api-format-options="apiFormatOptions"
|
||||
:model-options="modelOptions"
|
||||
:help-text="groupPolicyHelpTextLocalized"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,13 +52,13 @@
|
||||
:disabled="saving"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving || !form.name.trim()"
|
||||
@click="saveGroup"
|
||||
>
|
||||
保存
|
||||
{{ legacyT('保存') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -264,27 +66,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BadgeCheck, ChevronRight, Info, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useUserAccessControlOptions } from '@/features/users/composables/useUserAccessControlOptions'
|
||||
import UserGroupAccessControlFields from './UserGroupAccessControlFields.vue'
|
||||
import UserGroupEditorHeader from './UserGroupEditorHeader.vue'
|
||||
import UserGroupListPanel from './UserGroupListPanel.vue'
|
||||
import UserGroupProfileFields from './UserGroupProfileFields.vue'
|
||||
import type {
|
||||
ListPolicyMode,
|
||||
RateLimitPolicyMode,
|
||||
@@ -292,6 +87,7 @@ import type {
|
||||
User,
|
||||
UserGroup,
|
||||
} from '@/api/users'
|
||||
import type { UserGroupFormState } from './user-management-types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -306,6 +102,7 @@ const emit = defineEmits<{
|
||||
const usersStore = useUsersStore()
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmInfo } = useConfirm()
|
||||
const { legacyT, locale } = useI18n()
|
||||
const {
|
||||
providerOptions,
|
||||
apiFormatOptions,
|
||||
@@ -324,18 +121,11 @@ let dialogUsersLoadedAt = 0
|
||||
let dialogUsersLoadedVersion = -1
|
||||
|
||||
const groupPolicyHelpText = '模型、供应商和端点会在多个用户组之间叠加授权;unrestricted 仍表示不限制,deny_all 只是不授予额外权限。速率限制按付费档位取更高额度,0 表示不限速;用户/API Key 自身限制仍会收窄最终权限。'
|
||||
const groupPolicyHelpTextLocalized = computed(() => locale.value === 'en-US'
|
||||
? 'Models, providers, and endpoints accumulate across multiple user groups. unrestricted still means no restriction, while deny_all grants no extra permission. Rate limits take the higher quota by tier, and 0 means unlimited. User/API key limits still narrow the final permissions.'
|
||||
: groupPolicyHelpText)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
allowed_providers_mode: 'unrestricted' as ListPolicyMode,
|
||||
allowed_api_formats_mode: 'unrestricted' as ListPolicyMode,
|
||||
allowed_models_mode: 'unrestricted' as ListPolicyMode,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_api_formats: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
rate_limit_mode: 'system' as RateLimitPolicyMode,
|
||||
rate_limit: undefined as number | undefined,
|
||||
})
|
||||
const form = ref<UserGroupFormState>(createEmptyForm())
|
||||
|
||||
const selectedGroup = computed(() => groups.value.find((group) => group.id === editingGroupId.value) ?? null)
|
||||
const userOptions = computed(() => dialogUsers.value.map((user) => ({
|
||||
@@ -349,7 +139,7 @@ watch(
|
||||
if (!open) return
|
||||
void loadDialogData()
|
||||
void loadAccessControlOptions().catch((err) => {
|
||||
error(parseApiError(err, '加载访问控制选项失败'))
|
||||
error(parseApiError(err, '加载访问控制选项失败'), legacyT('加载访问控制选项失败'))
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -378,7 +168,7 @@ async function loadDialogData(): Promise<void> {
|
||||
startCreate()
|
||||
}
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载用户分组失败'))
|
||||
error(parseApiError(err, '加载用户分组失败'), legacyT('加载用户分组失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -423,7 +213,7 @@ async function selectGroup(groupId: string): Promise<void> {
|
||||
memberUserIds.value = members.map((member) => member.user_id)
|
||||
} catch (err) {
|
||||
memberUserIds.value = []
|
||||
error(parseApiError(err, '加载分组成员失败'))
|
||||
error(parseApiError(err, '加载分组成员失败'), legacyT('加载分组成员失败'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,9 +225,8 @@ function normalizeRateMode(mode: RateLimitPolicyMode): RateLimitPolicyMode {
|
||||
return mode === 'custom' ? 'custom' : 'system'
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
editingGroupId.value = null
|
||||
form.value = {
|
||||
function createEmptyForm(): UserGroupFormState {
|
||||
return {
|
||||
name: '',
|
||||
allowed_providers_mode: 'unrestricted',
|
||||
allowed_api_formats_mode: 'unrestricted',
|
||||
@@ -448,34 +237,32 @@ function startCreate(): void {
|
||||
rate_limit_mode: 'system',
|
||||
rate_limit: undefined,
|
||||
}
|
||||
memberUserIds.value = []
|
||||
}
|
||||
|
||||
function groupButtonClass(groupId: string): string {
|
||||
return cn(
|
||||
'flex w-full items-center gap-2 rounded-lg border px-3 py-2 transition-colors',
|
||||
editingGroupId.value === groupId
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:border-border hover:bg-background',
|
||||
)
|
||||
function startCreate(): void {
|
||||
editingGroupId.value = null
|
||||
form.value = createEmptyForm()
|
||||
memberUserIds.value = []
|
||||
}
|
||||
|
||||
async function toggleDefault(): Promise<void> {
|
||||
const group = selectedGroup.value
|
||||
if (!group || group.is_default) return
|
||||
const confirmed = await confirmInfo(
|
||||
`确定将「${group.name}」设为默认注册组吗?后续本地注册和 OAuth 自动创建的用户将加入该分组。`,
|
||||
'设为默认注册组',
|
||||
locale.value === 'en-US'
|
||||
? `Set "${group.name}" as the default registration group? Locally registered users and OAuth-created users will join this group.`
|
||||
: `确定将「${group.name}」设为默认注册组吗?后续本地注册和 OAuth 自动创建的用户将加入该分组。`,
|
||||
legacyT('设为默认注册组'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
saving.value = true
|
||||
try {
|
||||
await usersStore.setDefaultUserGroup(group.id)
|
||||
success('已更新默认注册组')
|
||||
success(legacyT('已更新默认注册组'))
|
||||
emit('changed')
|
||||
await loadDialogData()
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '设置默认注册组失败'))
|
||||
error(parseApiError(err, '设置默认注册组失败'), legacyT('设置默认注册组失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -513,12 +300,12 @@ async function saveGroup(): Promise<void> {
|
||||
if (!saved.is_default) {
|
||||
await usersStore.replaceUserGroupMembers(saved.id, memberUserIds.value)
|
||||
}
|
||||
success('用户分组已保存')
|
||||
success(legacyT('用户分组已保存'))
|
||||
emit('changed')
|
||||
editingGroupId.value = saved.id
|
||||
await loadDialogData()
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存用户分组失败'))
|
||||
error(parseApiError(err, '保存用户分组失败'), legacyT('保存用户分组失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -528,19 +315,21 @@ async function deleteSelectedGroup(): Promise<void> {
|
||||
if (!selectedGroup.value) return
|
||||
const group = selectedGroup.value
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除用户分组 ${group.name} 吗?成员关系会一并清理。`,
|
||||
'删除用户分组',
|
||||
locale.value === 'en-US'
|
||||
? `Delete user group ${group.name}? Member relationships will be cleaned up as well.`
|
||||
: `确定要删除用户分组 ${group.name} 吗?成员关系会一并清理。`,
|
||||
legacyT('删除用户分组'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
saving.value = true
|
||||
try {
|
||||
await usersStore.deleteUserGroup(group.id)
|
||||
success('用户分组已删除')
|
||||
success(legacyT('用户分组已删除'))
|
||||
emit('changed')
|
||||
editingGroupId.value = null
|
||||
await loadDialogData()
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '删除用户分组失败'))
|
||||
error(parseApiError(err, '删除用户分组失败'), legacyT('删除用户分组失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-10 w-10 flex-shrink-0 ring-2 ring-background shadow-md">
|
||||
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
||||
{{ row.user.username.charAt(0).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-1.5">
|
||||
<div
|
||||
class="truncate text-sm font-semibold"
|
||||
:title="row.user.username"
|
||||
>
|
||||
{{ row.user.username }}
|
||||
</div>
|
||||
<Badge
|
||||
:variant="row.roleBadgeVariant"
|
||||
class="h-5 flex-shrink-0 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ legacyT(row.roleLabel) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
:title="row.user.email || '-'"
|
||||
>
|
||||
{{ row.user.email || '-' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showGroups && row.user.groups?.length"
|
||||
class="mt-1 flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="group in row.user.groups"
|
||||
:key="group.id"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Avatar from '@/components/ui/avatar.vue'
|
||||
import AvatarFallback from '@/components/ui/avatar-fallback.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
row: UserManagementRow
|
||||
showGroups?: boolean
|
||||
}>(), {
|
||||
showGroups: true,
|
||||
})
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col gap-3 sm:hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ legacyT('用户管理') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('分组管理')"
|
||||
@click="$emit('openGroups')"
|
||||
>
|
||||
<FolderKanban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('新增用户')"
|
||||
@click="$emit('createUser')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserFilterControls
|
||||
:search-query="searchQuery"
|
||||
:filter-role="filterRole"
|
||||
:filter-group="filterGroup"
|
||||
:filter-status="filterStatus"
|
||||
:sort-option="sortOption"
|
||||
:user-groups="userGroups"
|
||||
:role-options="roleOptions"
|
||||
:status-options="statusOptions"
|
||||
:sort-options="sortOptions"
|
||||
mobile
|
||||
@update:search-query="$emit('update:searchQuery', $event)"
|
||||
@update:filter-role="$emit('update:filterRole', $event)"
|
||||
@update:filter-group="$emit('update:filterGroup', $event)"
|
||||
@update:filter-status="$emit('update:filterStatus', $event)"
|
||||
@update:sort-option="$emit('update:sortOption', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ legacyT('用户管理') }}
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UserFilterControls
|
||||
:search-query="searchQuery"
|
||||
:filter-role="filterRole"
|
||||
:filter-group="filterGroup"
|
||||
:filter-status="filterStatus"
|
||||
:sort-option="sortOption"
|
||||
:user-groups="userGroups"
|
||||
:role-options="roleOptions"
|
||||
:status-options="statusOptions"
|
||||
:sort-options="sortOptions"
|
||||
@update:search-query="$emit('update:searchQuery', $event)"
|
||||
@update:filter-role="$emit('update:filterRole', $event)"
|
||||
@update:filter-group="$emit('update:filterGroup', $event)"
|
||||
@update:filter-status="$emit('update:filterStatus', $event)"
|
||||
@update:sort-option="$emit('update:sortOption', $event)"
|
||||
/>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('分组管理')"
|
||||
@click="$emit('openGroups')"
|
||||
>
|
||||
<FolderKanban class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('新增用户')"
|
||||
@click="$emit('createUser')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderKanban, Plus } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import type { UserGroup, UserRole } from '@/api/users'
|
||||
import { useI18n } from '@/i18n'
|
||||
import UserFilterControls from './UserFilterControls.vue'
|
||||
|
||||
type FilterRole = 'all' | UserRole
|
||||
type FilterStatus = 'all' | 'active' | 'inactive'
|
||||
type SortOption = 'default' | 'created_at_desc' | 'created_at_asc'
|
||||
|
||||
interface FilterOption<TValue extends string = string> {
|
||||
value: TValue
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
searchQuery: string
|
||||
filterRole: FilterRole
|
||||
filterGroup: string
|
||||
filterStatus: FilterStatus
|
||||
sortOption: SortOption
|
||||
userGroups: UserGroup[]
|
||||
roleOptions: FilterOption<FilterRole>[]
|
||||
statusOptions: FilterOption<FilterStatus>[]
|
||||
sortOptions: FilterOption<SortOption>[]
|
||||
loading: boolean
|
||||
canOperateAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:filterRole': [value: FilterRole]
|
||||
'update:filterGroup': [value: string]
|
||||
'update:filterStatus': [value: FilterStatus]
|
||||
'update:sortOption': [value: SortOption]
|
||||
openGroups: []
|
||||
createUser: []
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="space-y-0">
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[44px] h-12 px-4">
|
||||
<Checkbox
|
||||
:checked="isCurrentPageFullySelected || isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected && !isCurrentPageFullySelected"
|
||||
:disabled="isHeaderCheckboxDisabled"
|
||||
@update:checked="handleToggleCurrentPage"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead class="w-[260px] h-12 font-semibold">
|
||||
{{ legacyT('用户信息') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-[240px] h-12 font-semibold">
|
||||
{{ legacyT('钱包') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-[170px] h-12 font-semibold">
|
||||
{{ legacyT('统计/限速') }}
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
class="w-[110px] h-12 font-semibold"
|
||||
column-key="created_at"
|
||||
:active-key="sortBy"
|
||||
:direction="sortOrder"
|
||||
default-direction="desc"
|
||||
:title="legacyT('按创建时间排序')"
|
||||
@sort="handleSort"
|
||||
>
|
||||
{{ legacyT('创建时间') }}
|
||||
</SortableTableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">
|
||||
{{ legacyT('状态') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-[260px] h-12 font-semibold text-center">
|
||||
{{ legacyT('操作') }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<UserTableRow
|
||||
v-for="row in rows"
|
||||
:key="row.user.id"
|
||||
:row="row"
|
||||
:selected="selectAllFiltered || selectedIdSet.has(row.user.id)"
|
||||
:selection-disabled="selectionDisabled"
|
||||
:can-operate-admin="canOperateAdmin"
|
||||
@toggle-selected="(checked) => emit('toggle-selected', row.user.id, checked)"
|
||||
@edit="emit('edit', row.user)"
|
||||
@wallet="emit('wallet', row.user)"
|
||||
@plans="emit('plans', row.user)"
|
||||
@api-keys="emit('api-keys', row.user)"
|
||||
@sessions="emit('sessions', row.user)"
|
||||
@toggle-status="emit('toggle-status', row.user)"
|
||||
@delete="emit('delete', row.user)"
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
|
||||
<UserMobileEmptyState
|
||||
v-if="rows.length === 0"
|
||||
:has-filters="hasFilters"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3.5"
|
||||
>
|
||||
<UserMobileCard
|
||||
v-for="row in rows"
|
||||
:key="row.user.id"
|
||||
:row="row"
|
||||
:selected="selectAllFiltered || selectedIdSet.has(row.user.id)"
|
||||
:selection-disabled="selectionDisabled"
|
||||
:can-operate-admin="canOperateAdmin"
|
||||
@toggle-selected="(checked) => emit('toggle-selected', row.user.id, checked)"
|
||||
@edit="emit('edit', row.user)"
|
||||
@wallet="emit('wallet', row.user)"
|
||||
@plans="emit('plans', row.user)"
|
||||
@api-keys="emit('api-keys', row.user)"
|
||||
@sessions="emit('sessions', row.user)"
|
||||
@toggle-status="emit('toggle-status', row.user)"
|
||||
@delete="emit('delete', row.user)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import SortableTableHead from '@/components/ui/sortable-table-head.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { AdminUserSortBy, AdminUserSortOrder } from '@/api/users'
|
||||
import UserMobileCard from './UserMobileCard.vue'
|
||||
import UserMobileEmptyState from './UserMobileEmptyState.vue'
|
||||
import UserTableRow from './UserTableRow.vue'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: UserManagementRow[]
|
||||
selectedIdSet: Set<string>
|
||||
selectAllFiltered: boolean
|
||||
isAllFilteredSelected: boolean
|
||||
isPartiallyFilteredSelected: boolean
|
||||
isCurrentPageFullySelected: boolean
|
||||
selectionDisabled: boolean
|
||||
loading: boolean
|
||||
canOperateAdmin: boolean
|
||||
hasFilters: boolean
|
||||
sortBy: AdminUserSortBy | null
|
||||
sortOrder: AdminUserSortOrder
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-selected': [userId: string, checked: boolean]
|
||||
'toggle-select-current-page': []
|
||||
edit: [user: UserManagementRow['user']]
|
||||
wallet: [user: UserManagementRow['user']]
|
||||
plans: [user: UserManagementRow['user']]
|
||||
'api-keys': [user: UserManagementRow['user']]
|
||||
sessions: [user: UserManagementRow['user']]
|
||||
'toggle-status': [user: UserManagementRow['user']]
|
||||
delete: [user: UserManagementRow['user']]
|
||||
sort: [payload: { key: string; direction: AdminUserSortOrder }]
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const isHeaderCheckboxDisabled = computed(() => props.rows.length === 0 || props.selectAllFiltered || props.loading)
|
||||
|
||||
function handleToggleCurrentPage(): void {
|
||||
emit('toggle-select-current-page')
|
||||
}
|
||||
|
||||
function handleSort(payload: { key: string; direction: AdminUserSortOrder }): void {
|
||||
emit('sort', payload)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
class="mt-2 shrink-0"
|
||||
:checked="selected"
|
||||
:disabled="selectionDisabled"
|
||||
@update:checked="(checked) => $emit('toggle-selected', checked === true)"
|
||||
/>
|
||||
<UserIdentityCell
|
||||
:row="row"
|
||||
:show-groups="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserStatusBadges
|
||||
:row="row"
|
||||
mobile
|
||||
/>
|
||||
|
||||
<UserWalletSummary
|
||||
:row="row"
|
||||
mobile
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2.5 text-xs">
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
{{ legacyT('请求次数') }}
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ row.requestCountLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||
<div class="mb-1 text-muted-foreground">
|
||||
Tokens
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ row.tokensLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{{ legacyT('创建时间') }}</span>
|
||||
<span class="font-medium text-foreground">{{ row.createdAtLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserActionButtons
|
||||
:can-operate-admin="canOperateAdmin"
|
||||
:is-active="row.user.is_active"
|
||||
mobile
|
||||
@edit="$emit('edit')"
|
||||
@wallet="$emit('wallet')"
|
||||
@plans="$emit('plans')"
|
||||
@api-keys="$emit('api-keys')"
|
||||
@sessions="$emit('sessions')"
|
||||
@toggle-status="$emit('toggle-status')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import UserActionButtons from './UserActionButtons.vue'
|
||||
import UserIdentityCell from './UserIdentityCell.vue'
|
||||
import UserStatusBadges from './UserStatusBadges.vue'
|
||||
import UserWalletSummary from './UserWalletSummary.vue'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
defineProps<{
|
||||
row: UserManagementRow
|
||||
selected: boolean
|
||||
selectionDisabled: boolean
|
||||
canOperateAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-selected': [checked: boolean]
|
||||
edit: []
|
||||
wallet: []
|
||||
plans: []
|
||||
'api-keys': []
|
||||
sessions: []
|
||||
'toggle-status': []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center">
|
||||
<Avatar class="mx-auto mb-3 h-12 w-12">
|
||||
<AvatarFallback class="bg-muted text-base font-semibold text-muted-foreground">
|
||||
U
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ hasFilters ? legacyT('未找到匹配的用户') : legacyT('暂无用户') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasFilters"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('尝试调整筛选条件') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Avatar from '@/components/ui/avatar.vue'
|
||||
import AvatarFallback from '@/components/ui/avatar-fallback.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
hasFilters: boolean
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
size="xl"
|
||||
@update:model-value="(value) => !value && $emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
|
||||
<PackageCheck class="h-5 w-5 text-kraft" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
||||
{{ legacyT('用户套餐') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ userName || '-' }} · {{ legacyT('查看当前套餐并手动发放') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-h-[64vh] space-y-4 overflow-y-auto">
|
||||
<div class="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-100/90">
|
||||
{{ legacyT('后台发放会立即生效;如果新套餐包含每日额度或会员权益,用户已有的同类旧套餐会自动失效。') }}
|
||||
</div>
|
||||
|
||||
<section class="space-y-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
{{ legacyT('当前有效套餐') }}
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="loadingEntitlements || !userId"
|
||||
@click="userId && $emit('refresh-entitlements', userId)"
|
||||
>
|
||||
{{ loadingEntitlements ? legacyT('加载中...') : legacyT('刷新') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingEntitlements"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('正在加载用户套餐...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entitlements.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('当前没有有效套餐') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="item in entitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border bg-card/80 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-foreground">
|
||||
{{ item.plan_title || item.plan?.title || item.plan_id }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="item.active ? 'success' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ item.active ? legacyT('生效中') : item.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="label in entitlementLabels(item.entitlements)"
|
||||
:key="label"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-foreground">
|
||||
{{ legacyT('发放套餐') }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('仅发放套餐权益,不产生用户付款;同类旧套餐会按现有规则自动替换。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="selectedPlanId"
|
||||
@update:model-value="$emit('update:selectedPlanId', $event)"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-9 rounded-md bg-muted/50 px-3"
|
||||
:disabled="loadingPlans || plans.length === 0"
|
||||
>
|
||||
<SelectValue :placeholder="loadingPlans ? legacyT('加载套餐中...') : legacyT('选择要发放的套餐')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:value="plan.id"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate">{{ plan.title }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ formatPlanPrice(plan) }} · {{ formatPlanDuration(plan) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!plan.enabled"
|
||||
class="shrink-0 text-[10px] text-amber-400"
|
||||
>
|
||||
{{ legacyT('已下架') }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Textarea
|
||||
:model-value="grantReason"
|
||||
class="min-h-[60px] resize-y rounded-md bg-muted/50 text-sm"
|
||||
maxlength="512"
|
||||
:placeholder="legacyT('备注(可选,例如:人工补偿、活动赠送)')"
|
||||
@update:model-value="$emit('update:grantReason', $event)"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="granting || !userId || !selectedPlanId"
|
||||
@click="$emit('grant')"
|
||||
>
|
||||
{{ granting ? legacyT('发放中...') : legacyT('发放套餐') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageCheck } from 'lucide-vue-next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { AdminUserPlanEntitlement } from '@/api/users'
|
||||
import type { BillingEntitlement, BillingPlan } from '@/api/billing'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
userId?: string | null
|
||||
userName?: string
|
||||
entitlements: AdminUserPlanEntitlement[]
|
||||
plans: BillingPlan[]
|
||||
selectedPlanId: string
|
||||
grantReason: string
|
||||
loadingEntitlements: boolean
|
||||
loadingPlans: boolean
|
||||
granting: boolean
|
||||
formatDateTime: (value?: string | null) => string
|
||||
formatPlanPrice: (plan: BillingPlan) => string
|
||||
formatPlanDuration: (plan: BillingPlan) => string
|
||||
entitlementLabels: (items: BillingEntitlement[] | undefined) => string[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
'update:selectedPlanId': [value: string]
|
||||
'update:grantReason': [value: string]
|
||||
'refresh-entitlements': [userId: string]
|
||||
grant: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-6 xl:px-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredUserCount === 0 || loading"
|
||||
@update:checked="handleToggleSelectFiltered"
|
||||
/>
|
||||
<span>{{ legacyT('全选筛选结果') }}</span>
|
||||
</label>
|
||||
<span>{{ selectionSummary }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="currentPageCount === 0 || selectAllFiltered || loading"
|
||||
@click="$emit('toggleSelectCurrentPage')"
|
||||
>
|
||||
{{ legacyT(isCurrentPageFullySelected ? '取消本页全选' : '本页全选') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="!canClearSelection || loading"
|
||||
@click="$emit('clearSelection')"
|
||||
>
|
||||
{{ legacyT('清空选择') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canOperateAdmin"
|
||||
size="sm"
|
||||
class="h-7 px-3 text-[11px]"
|
||||
:disabled="(selectedCount === 0 && groupCount === 0) || loading"
|
||||
@click="$emit('openBatchDialog')"
|
||||
>
|
||||
{{ legacyT('批量操作') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
isAllFilteredSelected: boolean
|
||||
isPartiallyFilteredSelected: boolean
|
||||
filteredUserCount: number
|
||||
currentPageCount: number
|
||||
selectedCount: number
|
||||
isCurrentPageFullySelected: boolean
|
||||
canClearSelection: boolean
|
||||
selectAllFiltered: boolean
|
||||
loading: boolean
|
||||
canOperateAdmin: boolean
|
||||
groupCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSelectFiltered: [checked: boolean]
|
||||
toggleSelectCurrentPage: []
|
||||
clearSelection: []
|
||||
openBatchDialog: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const selectionSummary = computed(() => {
|
||||
return legacyT(`匹配 ${props.filteredUserCount} 个,当前页 ${props.currentPageCount} 个,已选 ${props.selectedCount} 个`)
|
||||
})
|
||||
|
||||
function handleToggleSelectFiltered(value: boolean | 'indeterminate') {
|
||||
emit('toggleSelectFiltered', value === true)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
size="xl"
|
||||
@update:model-value="(value) => !value && $emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<MonitorSmartphone class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
||||
{{ legacyT('登录设备') }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('查看并强制下线该用户的设备会话') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-h-[60vh] space-y-3 overflow-y-auto">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('正在加载设备会话...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sessions.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('暂无在线设备') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/30"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ session.device_label }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ formatSessionMeta(session) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ legacyT('最近活跃') }} {{ formatDate(session.last_seen_at || session.created_at) }}
|
||||
<span v-if="session.ip_address"> · IP {{ session.ip_address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="actionLoading === session.id"
|
||||
@click="$emit('revoke-session', session.id)"
|
||||
>
|
||||
{{ actionLoading === session.id ? legacyT('处理中...') : legacyT('强制下线') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 px-5"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
<Button
|
||||
class="h-10 px-5"
|
||||
:disabled="loading || sessions.length === 0 || actionLoading === 'all'"
|
||||
@click="$emit('revoke-all')"
|
||||
>
|
||||
{{ actionLoading === 'all' ? legacyT('处理中...') : legacyT('全部下线') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MonitorSmartphone } from 'lucide-vue-next'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserSession } from '@/api/users'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
sessions: UserSession[]
|
||||
loading: boolean
|
||||
actionLoading: string | null
|
||||
formatDate: (dateString: string) => string
|
||||
formatSessionMeta: (session: UserSession) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
'revoke-session': [sessionId: string]
|
||||
'revoke-all': []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div :class="mobile ? 'flex flex-wrap items-center gap-1.5' : 'flex flex-col items-start gap-1.5'">
|
||||
<Badge
|
||||
:variant="row.statusVariant"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ legacyT(row.statusLabel) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="row.hasWallet"
|
||||
:variant="row.walletStatusVariant"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ legacyT(row.walletStatusLabel) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="mobile"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
:title="legacyT(row.rateLimitSource)"
|
||||
>
|
||||
{{ legacyT(row.rateLimitLabel) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="group in mobile ? row.user.groups || [] : []"
|
||||
:key="group.id"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
row: UserManagementRow
|
||||
mobile?: boolean
|
||||
}>(), {
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<TableRow class="border-b border-border/40 transition-colors hover:bg-muted/30">
|
||||
<TableCell class="w-[44px] px-4 py-4">
|
||||
<Checkbox
|
||||
:checked="selected"
|
||||
:disabled="selectionDisabled"
|
||||
@update:checked="(checked) => $emit('toggle-selected', checked === true)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<UserIdentityCell :row="row" />
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<UserWalletSummary :row="row" />
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">{{ legacyT('请求:') }}</span>
|
||||
<span class="font-medium text-foreground">{{ row.requestCountLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">Tokens:</span>
|
||||
<span class="font-medium text-foreground">{{ row.tokensLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">{{ legacyT('限速:') }}</span>
|
||||
<Badge
|
||||
v-if="row.rateLimitAsBadge"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ legacyT(row.rateLimitLabel) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-else
|
||||
class="font-medium text-foreground"
|
||||
>
|
||||
{{ legacyT(row.rateLimitLabel) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||
{{ row.createdAtLabel }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<UserStatusBadges :row="row" />
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<UserActionButtons
|
||||
:can-operate-admin="canOperateAdmin"
|
||||
:is-active="row.user.is_active"
|
||||
@edit="$emit('edit')"
|
||||
@wallet="$emit('wallet')"
|
||||
@plans="$emit('plans')"
|
||||
@api-keys="$emit('api-keys')"
|
||||
@sessions="$emit('sessions')"
|
||||
@toggle-status="$emit('toggle-status')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import UserActionButtons from './UserActionButtons.vue'
|
||||
import UserIdentityCell from './UserIdentityCell.vue'
|
||||
import UserStatusBadges from './UserStatusBadges.vue'
|
||||
import UserWalletSummary from './UserWalletSummary.vue'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
defineProps<{
|
||||
row: UserManagementRow
|
||||
selected: boolean
|
||||
selectionDisabled: boolean
|
||||
canOperateAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-selected': [checked: boolean]
|
||||
edit: []
|
||||
wallet: []
|
||||
plans: []
|
||||
'api-keys': []
|
||||
sessions: []
|
||||
'toggle-status': []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div :class="mobile ? 'rounded-xl border border-border/60 bg-muted/40 p-3.5' : 'space-y-1.5'">
|
||||
<div :class="mobile ? 'flex items-start justify-between gap-3' : ''">
|
||||
<div class="space-y-1">
|
||||
<p :class="mobile ? 'text-[11px] text-muted-foreground' : 'flex items-center gap-1 text-[11px] text-muted-foreground'">
|
||||
<span>{{ legacyT('总可用:') }}</span>
|
||||
<Badge
|
||||
v-if="row.isUnlimited"
|
||||
variant="secondary"
|
||||
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
||||
>
|
||||
{{ legacyT('无限额度') }}
|
||||
</Badge>
|
||||
<span
|
||||
v-else
|
||||
:class="[
|
||||
mobile ? 'text-base leading-none' : 'text-sm',
|
||||
'font-semibold tabular-nums',
|
||||
row.isNegativeBalance ? 'text-rose-600' : 'text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ row.totalBalanceLabel }}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="!row.isUnlimited && row.hasWallet"
|
||||
class="text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('套餐') }} {{ row.packageBalanceLabel }}
|
||||
· {{ legacyT('钱包') }} {{ row.walletBalanceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="mobile ? 'text-right' : 'flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap'">
|
||||
<p :class="mobile ? 'text-[11px] text-muted-foreground' : ''">
|
||||
{{ legacyT('已消费:') }}
|
||||
</p>
|
||||
<p :class="mobile ? 'text-sm font-medium tabular-nums text-foreground' : 'font-medium tabular-nums text-foreground'">
|
||||
{{ row.consumedLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { UserManagementRow } from './user-management-types'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
row: UserManagementRow
|
||||
mobile?: boolean
|
||||
}>(), {
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
} from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
import type { UserBatchAction, UserRole } from '@/api/users'
|
||||
import type {
|
||||
UserFilterOption,
|
||||
UserFilterRole,
|
||||
UserFilterStatus,
|
||||
UserSortOption,
|
||||
} from './user-management-types'
|
||||
|
||||
export interface UserBatchActionOption {
|
||||
value: UserBatchAction
|
||||
label: string
|
||||
description: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export const USER_ROLE_FILTER_OPTIONS: UserFilterOption<UserFilterRole>[] = [
|
||||
{ value: 'all', label: '全部角色' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'audit_admin', label: '审计管理员' },
|
||||
{ value: 'user', label: '普通用户' },
|
||||
]
|
||||
|
||||
export const USER_STATUS_FILTER_OPTIONS: UserFilterOption<UserFilterStatus>[] = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '活跃' },
|
||||
{ value: 'inactive', label: '禁用' },
|
||||
]
|
||||
|
||||
export const USER_SORT_OPTIONS: UserFilterOption<UserSortOption>[] = [
|
||||
{ value: 'default', label: '默认排序' },
|
||||
{ value: 'created_at_desc', label: '创建时间 新到旧' },
|
||||
{ value: 'created_at_asc', label: '创建时间 旧到新' },
|
||||
]
|
||||
|
||||
export const USER_BATCH_ACTION_OPTIONS: UserBatchActionOption[] = [
|
||||
{
|
||||
value: 'enable',
|
||||
label: '启用',
|
||||
description: '恢复用户登录与调用',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
value: 'disable',
|
||||
label: '禁用',
|
||||
description: '暂停用户访问权限',
|
||||
icon: Ban,
|
||||
},
|
||||
{
|
||||
value: 'update_access_control',
|
||||
label: '额度',
|
||||
description: '批量调整用户额度模式',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
value: 'update_role',
|
||||
label: '修改角色',
|
||||
description: '批量设为普通用户或管理员',
|
||||
icon: UserCog,
|
||||
},
|
||||
]
|
||||
|
||||
export function formatUserRoleLabel(role: UserRole | string): string {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'audit_admin') return '审计管理员'
|
||||
return '普通用户'
|
||||
}
|
||||
|
||||
export function userRoleBadgeVariant(role: UserRole | string): 'default' | 'secondary' {
|
||||
return role === 'admin' ? 'default' : 'secondary'
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ListPolicyMode, RateLimitPolicyMode, User, UserRole } from '@/api/users'
|
||||
|
||||
export type UserFilterRole = 'all' | UserRole
|
||||
export type UserFilterStatus = 'all' | 'active' | 'inactive'
|
||||
export type UserSortOption = 'default' | 'created_at_desc' | 'created_at_asc'
|
||||
export type UserBatchQuotaMode = 'skip' | 'wallet' | 'unlimited'
|
||||
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
||||
|
||||
export interface UserFilterOption<TValue extends string = string> {
|
||||
value: TValue
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface UserSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface UserGroupFormState {
|
||||
name: string
|
||||
allowed_providers_mode: ListPolicyMode
|
||||
allowed_api_formats_mode: ListPolicyMode
|
||||
allowed_models_mode: ListPolicyMode
|
||||
allowed_providers: string[]
|
||||
allowed_api_formats: string[]
|
||||
allowed_models: string[]
|
||||
rate_limit_mode: RateLimitPolicyMode
|
||||
rate_limit: number | undefined
|
||||
}
|
||||
|
||||
export interface UserManagementRow {
|
||||
user: User
|
||||
roleLabel: string
|
||||
roleBadgeVariant: BadgeVariant
|
||||
isUnlimited: boolean
|
||||
hasWallet: boolean
|
||||
totalBalanceLabel: string
|
||||
packageBalanceLabel: string
|
||||
walletBalanceLabel: string
|
||||
consumedLabel: string
|
||||
isNegativeBalance: boolean
|
||||
walletStatusLabel: string
|
||||
walletStatusVariant: BadgeVariant
|
||||
requestCountLabel: string
|
||||
tokensLabel: string
|
||||
rateLimitLabel: string
|
||||
rateLimitSource: string
|
||||
rateLimitAsBadge: boolean
|
||||
createdAtLabel: string
|
||||
statusLabel: string
|
||||
statusVariant: BadgeVariant
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import { createI18n, useI18n, useLocaleOptions } from '@/i18n'
|
||||
import { translateLegacyText } from '@/i18n/messages'
|
||||
import { transformLegacyTemplateI18n } from '@/i18n/legacy-template-transform'
|
||||
|
||||
describe('i18n infrastructure', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.lang = ''
|
||||
})
|
||||
|
||||
it('installs a single app-level translator and updates document language', () => {
|
||||
const app = createApp(defineComponent({ setup: () => () => h('div') }))
|
||||
|
||||
app.use(createI18n())
|
||||
|
||||
expect(['zh-CN', 'en-US']).toContain(document.documentElement.lang)
|
||||
expect(app.config.globalProperties.$t('site.home.login')).toBeTruthy()
|
||||
expect(app.config.globalProperties.$legacyT('保存')).toBeTruthy()
|
||||
expect(localStorage.getItem('aether_locale')).toBe(document.documentElement.lang)
|
||||
})
|
||||
|
||||
it('switches locale, persists it, and interpolates params', async () => {
|
||||
const Probe = defineComponent({
|
||||
setup() {
|
||||
const { t, setLocale } = useI18n()
|
||||
const { currentLocaleLabel } = useLocaleOptions()
|
||||
|
||||
return { t, setLocale, currentLocaleLabel }
|
||||
},
|
||||
render() {
|
||||
return h('div', [
|
||||
h('span', { id: 'label' }, this.currentLocaleLabel),
|
||||
h('span', { id: 'message' }, this.t('site.privacy.currentVersion', { version: '2' })),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Probe)
|
||||
app.use(createI18n())
|
||||
const vm = app.mount(root) as InstanceType<typeof Probe> & {
|
||||
setLocale: (locale: 'zh-CN' | 'en-US') => void
|
||||
}
|
||||
|
||||
vm.setLocale('en-US')
|
||||
await nextTick()
|
||||
|
||||
expect(document.documentElement.lang).toBe('en-US')
|
||||
expect(localStorage.getItem('aether_locale')).toBe('en-US')
|
||||
expect(root.textContent).toContain('English')
|
||||
expect(root.textContent).toContain('Current version: 2')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('exposes legacy text translation through the app-level helper', async () => {
|
||||
const Probe = defineComponent({
|
||||
setup() {
|
||||
const { legacyT, setLocale } = useI18n()
|
||||
const source = ref('保存')
|
||||
|
||||
return { legacyT, setLocale, source }
|
||||
},
|
||||
render() {
|
||||
return h('button', { title: this.legacyT('关闭') }, this.legacyT(this.source))
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Probe)
|
||||
app.use(createI18n())
|
||||
const vm = app.mount(root) as InstanceType<typeof Probe> & {
|
||||
setLocale: (locale: 'zh-CN' | 'en-US') => void
|
||||
source: string
|
||||
}
|
||||
|
||||
vm.setLocale('en-US')
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('button')?.textContent).toBe('Save')
|
||||
expect(root.querySelector('button')?.getAttribute('title')).toBe('Close')
|
||||
|
||||
vm.source = '保存中...'
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('button')?.textContent).toBe('Saving...')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('rewrites template text and static attributes without touching code blocks', () => {
|
||||
const result = transformLegacyTemplateI18n(`
|
||||
<button title="关闭">取消</button>
|
||||
<input placeholder="全部状态">
|
||||
<code>复制 配置</code>
|
||||
`)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.needsHelper).toBe(true)
|
||||
expect(result.code).toContain(`:title='__aetherLegacyT("关闭")'`)
|
||||
expect(result.code).toContain(`{{ __aetherLegacyT("取消") }}`)
|
||||
expect(result.code).toContain(`:placeholder='__aetherLegacyT("全部状态")'`)
|
||||
expect(result.code).toContain('<code>复制 配置</code>')
|
||||
})
|
||||
|
||||
it('translates common legacy phrases without adding new message entry points', () => {
|
||||
expect(translateLegacyText('请求记录清理策略', 'en-US')).toBe('Request log cleanup policy')
|
||||
expect(translateLegacyText(' 发布于 2026-01-01 ', 'en-US')).toBe(' Published at 2026-01-01 ')
|
||||
expect(translateLegacyText('git clone https://github.com/fawney19/Aether.git', 'en-US')).toBe('git clone https://github.com/fawney19/Aether.git')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { translateLegacyText, type Locale } from './messages'
|
||||
|
||||
const cjkPattern = /[\u4e00-\u9fff]/
|
||||
const skippedTags = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'KBD', 'SAMP', 'TEXTAREA'])
|
||||
const translatableAttributes = ['alt', 'aria-label', 'placeholder', 'title']
|
||||
|
||||
const originalText = new WeakMap<Text, string>()
|
||||
const originalAttributes = new WeakMap<Element, Map<string, string>>()
|
||||
|
||||
let observer: MutationObserver | null = null
|
||||
let scheduled = false
|
||||
|
||||
function shouldSkipElement(element: Element | null): boolean {
|
||||
let current: Element | null = element
|
||||
while (current) {
|
||||
if (skippedTags.has(current.tagName) || current.hasAttribute('contenteditable')) {
|
||||
return true
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text, locale: Locale): void {
|
||||
if (shouldSkipElement(node.parentElement)) return
|
||||
|
||||
if (locale !== 'en-US') {
|
||||
const original = originalText.get(node)
|
||||
if (original !== undefined && node.nodeValue !== original) {
|
||||
node.nodeValue = original
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const current = node.nodeValue ?? ''
|
||||
const source = originalText.get(node) ?? current
|
||||
if (!cjkPattern.test(source)) return
|
||||
|
||||
const translated = translateLegacyText(source, locale)
|
||||
if (!originalText.has(node)) {
|
||||
originalText.set(node, source)
|
||||
}
|
||||
if (translated !== current) {
|
||||
node.nodeValue = translated
|
||||
}
|
||||
}
|
||||
|
||||
function translateElementAttributes(element: Element, locale: Locale): void {
|
||||
if (shouldSkipElement(element)) return
|
||||
|
||||
let originals = originalAttributes.get(element)
|
||||
|
||||
for (const attribute of translatableAttributes) {
|
||||
const current = element.getAttribute(attribute)
|
||||
if (current === null) continue
|
||||
|
||||
if (locale !== 'en-US') {
|
||||
const original = originals?.get(attribute)
|
||||
if (original !== undefined && current !== original) {
|
||||
element.setAttribute(attribute, original)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const source = originals?.get(attribute) ?? current
|
||||
if (!cjkPattern.test(source)) continue
|
||||
|
||||
const translated = translateLegacyText(source, locale)
|
||||
if (!originals) {
|
||||
originals = new Map()
|
||||
originalAttributes.set(element, originals)
|
||||
}
|
||||
if (!originals.has(attribute)) {
|
||||
originals.set(attribute, source)
|
||||
}
|
||||
if (translated !== current) {
|
||||
element.setAttribute(attribute, translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function translateDom(root: ParentNode, locale: Locale): void {
|
||||
if (root instanceof Element) {
|
||||
translateElementAttributes(root, locale)
|
||||
}
|
||||
|
||||
const elements = root.querySelectorAll?.('*') ?? []
|
||||
for (const element of elements) {
|
||||
translateElementAttributes(element, locale)
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
translateTextNode(node as Text, locale)
|
||||
node = walker.nextNode()
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDomTranslation(locale: Ref<Locale>): void {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false
|
||||
if (document.body) {
|
||||
translateDom(document.body, locale.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function installLegacyDomTranslator(locale: Ref<Locale>): void {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
if (observer) return
|
||||
|
||||
void nextTick(() => scheduleDomTranslation(locale))
|
||||
|
||||
watch(locale, () => {
|
||||
void nextTick(() => scheduleDomTranslation(locale))
|
||||
})
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
scheduleDomTranslation(locale)
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: translatableAttributes,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { App, InjectionKey, Ref } from 'vue'
|
||||
import { computed, inject, readonly, ref } from 'vue'
|
||||
import {
|
||||
defaultLocale,
|
||||
messages,
|
||||
supportedLocales,
|
||||
translateLegacyText,
|
||||
type Locale,
|
||||
type MessageKey,
|
||||
} from './messages'
|
||||
import { installLegacyDomTranslator } from './dom-translator'
|
||||
|
||||
type Params = Record<string, string | number>
|
||||
|
||||
interface I18nContext {
|
||||
locale: Ref<Locale>
|
||||
setLocale: (locale: Locale) => void
|
||||
t: (key: MessageKey, params?: Params) => string
|
||||
legacyT: (value: string) => string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'aether_locale'
|
||||
const i18nKey: InjectionKey<I18nContext> = Symbol('aether-i18n')
|
||||
const locale = ref<Locale>(readInitialLocale())
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return !!value && supportedLocales.includes(value as Locale)
|
||||
}
|
||||
|
||||
function readInitialLocale(): Locale {
|
||||
if (typeof window === 'undefined') return defaultLocale
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (isLocale(stored)) return stored
|
||||
|
||||
const preferred = navigator.languages?.find(language => {
|
||||
const normalized = normalizeLocale(language)
|
||||
return isLocale(normalized)
|
||||
})
|
||||
const normalizedPreferred = normalizeLocale(preferred)
|
||||
return isLocale(normalizedPreferred) ? normalizedPreferred : defaultLocale
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
const lower = value.toLowerCase()
|
||||
if (lower.startsWith('zh')) return 'zh-CN'
|
||||
if (lower.startsWith('en')) return 'en-US'
|
||||
return value
|
||||
}
|
||||
|
||||
function setLocale(nextLocale: Locale): void {
|
||||
locale.value = nextLocale
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = nextLocale
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, nextLocale)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(template: string, params?: Params): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`))
|
||||
}
|
||||
|
||||
function t(key: MessageKey, params?: Params): string {
|
||||
const bundle = messages[locale.value] ?? messages[defaultLocale]
|
||||
const template = bundle[key] ?? messages[defaultLocale][key] ?? key
|
||||
return formatMessage(template, params)
|
||||
}
|
||||
|
||||
function legacyT(value: string): string {
|
||||
return translateLegacyText(value, locale.value)
|
||||
}
|
||||
|
||||
const context: I18nContext = {
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
legacyT,
|
||||
}
|
||||
|
||||
export function createI18n() {
|
||||
return {
|
||||
install(app: App) {
|
||||
app.provide(i18nKey, context)
|
||||
app.config.globalProperties.$t = t
|
||||
app.config.globalProperties.$legacyT = legacyT
|
||||
setLocale(locale.value)
|
||||
installLegacyDomTranslator(locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return inject(i18nKey, context)
|
||||
}
|
||||
|
||||
export function setI18nLocale(locale: Locale): void {
|
||||
setLocale(locale)
|
||||
}
|
||||
|
||||
export function getI18nLocale(): Locale {
|
||||
return locale.value
|
||||
}
|
||||
|
||||
export function useLocaleOptions() {
|
||||
const { locale: currentLocale, setLocale: applyLocale } = useI18n()
|
||||
const currentLocaleLabel = computed(() => {
|
||||
return currentLocale.value === 'zh-CN' ? t('common.chinese') : t('common.english')
|
||||
})
|
||||
|
||||
return {
|
||||
locale: readonly(currentLocale),
|
||||
supportedLocales,
|
||||
currentLocaleLabel,
|
||||
setLocale: applyLocale,
|
||||
}
|
||||
}
|
||||
|
||||
export type { Locale, MessageKey }
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const cjkPattern = /[\u4e00-\u9fff]/
|
||||
const helperName = '__aetherLegacyT'
|
||||
const helperImportName = '__useAetherI18n'
|
||||
|
||||
const skipTags = new Set(['script', 'style', 'code', 'pre', 'kbd', 'samp', 'textarea'])
|
||||
const voidTags = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
])
|
||||
|
||||
const translatableAttributeNames = new Set([
|
||||
'alt',
|
||||
'aria-label',
|
||||
'cancel-text',
|
||||
'client-label',
|
||||
'confirm-text',
|
||||
'description',
|
||||
'drop-title',
|
||||
'empty-message',
|
||||
'empty-text',
|
||||
'entity-label',
|
||||
'filter-title',
|
||||
'label',
|
||||
'manual-placeholder',
|
||||
'message',
|
||||
'path-hint',
|
||||
'placeholder',
|
||||
'provider-label',
|
||||
'search-placeholder',
|
||||
'subtitle',
|
||||
'title',
|
||||
])
|
||||
|
||||
interface TemplateTransformResult {
|
||||
code: string
|
||||
changed: boolean
|
||||
needsHelper: boolean
|
||||
}
|
||||
|
||||
interface TagInfo {
|
||||
closing: boolean
|
||||
name: string
|
||||
selfClosing: boolean
|
||||
skipSubtree: boolean
|
||||
}
|
||||
|
||||
interface TagStackEntry {
|
||||
name: string
|
||||
skip: boolean
|
||||
}
|
||||
|
||||
function toExpressionString(value: string): string {
|
||||
return JSON.stringify(value).replace(/'/g, "\\'")
|
||||
}
|
||||
|
||||
function wrapExpression(expression: string): string {
|
||||
const trimmed = expression.trim()
|
||||
if (!trimmed || trimmed.includes(helperName)) {
|
||||
return expression
|
||||
}
|
||||
|
||||
return `${helperName}(${trimmed})`
|
||||
}
|
||||
|
||||
function renderTranslatedText(value: string): string {
|
||||
return `{{ ${helperName}(${toExpressionString(value)}) }}`
|
||||
}
|
||||
|
||||
function findTagEnd(source: string, start: number): number {
|
||||
let quote: string | null = null
|
||||
|
||||
for (let index = start; index < source.length; index++) {
|
||||
const char = source[index]
|
||||
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '>') {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function looksLikeTagStart(source: string, index: number): boolean {
|
||||
const next = source[index + 1]
|
||||
return !!next && /[A-Za-z!/]/.test(next)
|
||||
}
|
||||
|
||||
function parseTagInfo(tag: string): TagInfo | null {
|
||||
if (tag.startsWith('<!--') || tag.startsWith('<!') || tag.startsWith('<?')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = tag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:._-]*)/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = match[2].toLowerCase()
|
||||
const closing = !!match[1]
|
||||
const selfClosing = closing ? false : /\/\s*>$/.test(tag) || voidTags.has(name)
|
||||
const skipSubtree = !closing && (skipTags.has(name) || /\sv-pre(?:[\s=>]|$)/.test(tag))
|
||||
|
||||
return {
|
||||
closing,
|
||||
name,
|
||||
selfClosing,
|
||||
skipSubtree,
|
||||
}
|
||||
}
|
||||
|
||||
function isTranslatableAttribute(attributeName: string): boolean {
|
||||
const normalized = attributeName
|
||||
.replace(/^:/, '')
|
||||
.replace(/^v-bind:/, '')
|
||||
.split('.')[0]
|
||||
.toLowerCase()
|
||||
|
||||
return translatableAttributeNames.has(normalized)
|
||||
}
|
||||
|
||||
function isBoundAttribute(attributeName: string): boolean {
|
||||
return attributeName.startsWith(':') || attributeName.startsWith('v-bind:')
|
||||
}
|
||||
|
||||
function transformTagAttributes(tag: string): TemplateTransformResult {
|
||||
let changed = false
|
||||
let needsHelper = false
|
||||
const attributePattern = /(\s)([:@]?[A-Za-z_][\w:.-]*)(\s*=\s*)(["'])([\s\S]*?)\4/g
|
||||
|
||||
const code = tag.replace(
|
||||
attributePattern,
|
||||
(fullMatch, prefix: string, attributeName: string, equals: string, quote: string, value: string) => {
|
||||
if (!isTranslatableAttribute(attributeName) || attributeName.startsWith('@')) {
|
||||
return fullMatch
|
||||
}
|
||||
|
||||
if (isBoundAttribute(attributeName)) {
|
||||
const wrapped = wrapExpression(value)
|
||||
if (wrapped === value) {
|
||||
return fullMatch
|
||||
}
|
||||
|
||||
changed = true
|
||||
needsHelper = true
|
||||
return `${prefix}${attributeName}${equals}${quote}${wrapped}${quote}`
|
||||
}
|
||||
|
||||
if (!cjkPattern.test(value)) {
|
||||
return fullMatch
|
||||
}
|
||||
|
||||
changed = true
|
||||
needsHelper = true
|
||||
return `${prefix}:${attributeName}='${helperName}(${toExpressionString(value)})'`
|
||||
},
|
||||
)
|
||||
|
||||
return { code, changed, needsHelper }
|
||||
}
|
||||
|
||||
function transformTextSegment(segment: string): TemplateTransformResult {
|
||||
if (!segment) {
|
||||
return { code: segment, changed: false, needsHelper: false }
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let needsHelper = false
|
||||
let cursor = 0
|
||||
let code = ''
|
||||
const interpolationPattern = /\{\{([\s\S]*?)\}\}/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = interpolationPattern.exec(segment))) {
|
||||
const staticText = segment.slice(cursor, match.index)
|
||||
if (cjkPattern.test(staticText)) {
|
||||
code += renderTranslatedText(staticText)
|
||||
changed = true
|
||||
needsHelper = true
|
||||
} else {
|
||||
code += staticText
|
||||
}
|
||||
|
||||
const expression = match[1]
|
||||
const wrapped = wrapExpression(expression)
|
||||
code += `{{ ${wrapped} }}`
|
||||
if (wrapped !== expression) {
|
||||
changed = true
|
||||
needsHelper = true
|
||||
}
|
||||
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
|
||||
const tail = segment.slice(cursor)
|
||||
if (cjkPattern.test(tail)) {
|
||||
code += renderTranslatedText(tail)
|
||||
changed = true
|
||||
needsHelper = true
|
||||
} else {
|
||||
code += tail
|
||||
}
|
||||
|
||||
return changed ? { code, changed, needsHelper } : { code: segment, changed: false, needsHelper: false }
|
||||
}
|
||||
|
||||
function closeTag(stack: TagStackEntry[], tagName: string): void {
|
||||
const index = stack.findLastIndex(entry => entry.name === tagName)
|
||||
if (index >= 0) {
|
||||
stack.splice(index)
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideSkippedTag(stack: TagStackEntry[]): boolean {
|
||||
return stack.some(entry => entry.skip)
|
||||
}
|
||||
|
||||
export function transformLegacyTemplateI18n(template: string): TemplateTransformResult {
|
||||
let code = ''
|
||||
let changed = false
|
||||
let needsHelper = false
|
||||
let cursor = 0
|
||||
const stack: TagStackEntry[] = []
|
||||
|
||||
while (cursor < template.length) {
|
||||
if (template.startsWith('<!--', cursor)) {
|
||||
const end = template.indexOf('-->', cursor + 4)
|
||||
const nextCursor = end >= 0 ? end + 3 : template.length
|
||||
code += template.slice(cursor, nextCursor)
|
||||
cursor = nextCursor
|
||||
continue
|
||||
}
|
||||
|
||||
if (template[cursor] !== '<' || !looksLikeTagStart(template, cursor)) {
|
||||
const nextTag = template.indexOf('<', cursor + 1)
|
||||
const nextCursor = nextTag >= 0 ? nextTag : template.length
|
||||
const segment = template.slice(cursor, nextCursor)
|
||||
|
||||
if (isInsideSkippedTag(stack)) {
|
||||
code += segment
|
||||
} else {
|
||||
const transformed = transformTextSegment(segment)
|
||||
code += transformed.code
|
||||
changed = changed || transformed.changed
|
||||
needsHelper = needsHelper || transformed.needsHelper
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
continue
|
||||
}
|
||||
|
||||
const tagEnd = findTagEnd(template, cursor)
|
||||
if (tagEnd < 0) {
|
||||
code += template.slice(cursor)
|
||||
break
|
||||
}
|
||||
|
||||
const rawTag = template.slice(cursor, tagEnd + 1)
|
||||
const info = parseTagInfo(rawTag)
|
||||
const shouldTransformAttributes = !!info && !info.closing && !isInsideSkippedTag(stack) && !info.skipSubtree
|
||||
|
||||
if (shouldTransformAttributes) {
|
||||
const transformed = transformTagAttributes(rawTag)
|
||||
code += transformed.code
|
||||
changed = changed || transformed.changed
|
||||
needsHelper = needsHelper || transformed.needsHelper
|
||||
} else {
|
||||
code += rawTag
|
||||
}
|
||||
|
||||
if (info) {
|
||||
if (info.closing) {
|
||||
closeTag(stack, info.name)
|
||||
} else if (!info.selfClosing) {
|
||||
stack.push({ name: info.name, skip: info.skipSubtree })
|
||||
}
|
||||
}
|
||||
|
||||
cursor = tagEnd + 1
|
||||
}
|
||||
|
||||
return { code, changed, needsHelper }
|
||||
}
|
||||
|
||||
function injectScriptSetupHelper(source: string): string {
|
||||
if (source.includes(`legacyT: ${helperName}`)) {
|
||||
return source
|
||||
}
|
||||
|
||||
const helperSource = `\nimport { useI18n as ${helperImportName} } from '@/i18n'\nconst { legacyT: ${helperName} } = ${helperImportName}()\n`
|
||||
const scriptSetupMatch = source.match(/<script\s+setup(?:\s[^>]*)?>/)
|
||||
|
||||
if (scriptSetupMatch?.index !== undefined) {
|
||||
const insertAt = scriptSetupMatch.index + scriptSetupMatch[0].length
|
||||
return `${source.slice(0, insertAt)}${helperSource}${source.slice(insertAt)}`
|
||||
}
|
||||
|
||||
return `${source}\n<script setup lang="ts">${helperSource}</script>\n`
|
||||
}
|
||||
|
||||
function transformVueSource(source: string): TemplateTransformResult {
|
||||
const templateMatch = source.match(/<template(?:\s[^>]*)?>([\s\S]*?)<\/template>/)
|
||||
if (!templateMatch || templateMatch.index === undefined) {
|
||||
return { code: source, changed: false, needsHelper: false }
|
||||
}
|
||||
|
||||
const templateContent = templateMatch[1]
|
||||
const transformed = transformLegacyTemplateI18n(templateContent)
|
||||
if (!transformed.changed) {
|
||||
return { code: source, changed: false, needsHelper: false }
|
||||
}
|
||||
|
||||
const templateStart = templateMatch.index + templateMatch[0].indexOf(templateContent)
|
||||
const templateEnd = templateStart + templateContent.length
|
||||
const nextSource = `${source.slice(0, templateStart)}${transformed.code}${source.slice(templateEnd)}`
|
||||
const code = transformed.needsHelper ? injectScriptSetupHelper(nextSource) : nextSource
|
||||
|
||||
return {
|
||||
code,
|
||||
changed: true,
|
||||
needsHelper: transformed.needsHelper,
|
||||
}
|
||||
}
|
||||
|
||||
export function legacyTemplateI18nPlugin(): Plugin {
|
||||
return {
|
||||
name: 'aether-legacy-template-i18n',
|
||||
enforce: 'pre',
|
||||
transform(source, id) {
|
||||
const filename = id.split('?')[0]
|
||||
if (!filename.endsWith('.vue')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transformed = transformVueSource(source)
|
||||
if (!transformed.changed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
code: transformed.code,
|
||||
map: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import type { MessageKey } from './messages'
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$t: (key: MessageKey, params?: Record<string, string | number>) => string
|
||||
$legacyT: (value: string) => string
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="flex w-full max-w-3xl items-center justify-between rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
<span>认证已过期,请重新登录</span>
|
||||
<span>{{ t('auth.expired') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -23,7 +23,7 @@
|
||||
class="border-white/60 text-white hover:bg-white/10"
|
||||
@click="handleRelogin"
|
||||
>
|
||||
重新登录
|
||||
{{ t('auth.relogin') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,13 +74,13 @@
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="p-1.5 hover:bg-muted/50 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="个人设置"
|
||||
:title="t('common.settings')"
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</RouterLink>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-muted-foreground hover:text-red-500 transition-colors"
|
||||
title="退出登录"
|
||||
:title="t('common.logout')"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
@@ -131,9 +131,10 @@
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||
:title="themeModeTitle"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<SunMoon
|
||||
@@ -244,12 +245,14 @@
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="p-2 hover:bg-muted/50 rounded-lg text-muted-foreground hover:text-foreground transition-colors"
|
||||
:title="t('common.settings')"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</RouterLink>
|
||||
<button
|
||||
class="p-2 rounded-lg text-muted-foreground hover:text-red-500 transition-colors"
|
||||
:title="t('common.logout')"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
@@ -298,7 +301,7 @@
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium"
|
||||
>
|
||||
<AlertTriangle class="w-3.5 h-3.5" />
|
||||
<span>演示模式</span>
|
||||
<span>{{ t('demo.mode') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -324,10 +327,11 @@
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<LanguageSwitcher />
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||
:title="themeModeTitle"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<SunMoon
|
||||
@@ -349,7 +353,7 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
title="GitHub 仓库"
|
||||
:title="t('common.githubRepository')"
|
||||
>
|
||||
<GithubIcon class="h-4 w-4" />
|
||||
</a>
|
||||
@@ -363,8 +367,8 @@
|
||||
v-model="requiredAnnouncementOpen"
|
||||
persistent
|
||||
size="lg"
|
||||
title="必读公告"
|
||||
description="请确认后继续使用"
|
||||
:title="t('announcement.requiredTitle')"
|
||||
:description="t('announcement.requiredDescription')"
|
||||
>
|
||||
<div
|
||||
v-if="currentRequiredAnnouncement"
|
||||
@@ -391,7 +395,7 @@
|
||||
:disabled="acknowledgingRequiredAnnouncement"
|
||||
@click="acknowledgeRequiredAnnouncement"
|
||||
>
|
||||
{{ acknowledgingRequiredAnnouncement ? '确认中...' : '确认已读' }}
|
||||
{{ acknowledgingRequiredAnnouncement ? t('common.confirming') : t('common.confirmRead') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -444,50 +448,27 @@ import { Dialog } from '@/components/ui'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
|
||||
import UpdateDialog from '@/components/common/UpdateDialog.vue'
|
||||
import VersionButton from '@/components/common/VersionButton.vue'
|
||||
import { buildUpdateErrorStatus } from '@/utils/updateStatus'
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Key,
|
||||
KeyRound,
|
||||
BarChart3,
|
||||
Cog,
|
||||
Settings,
|
||||
Activity,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
SunMedium,
|
||||
Moon,
|
||||
Gauge,
|
||||
Layers,
|
||||
FolderTree,
|
||||
Database,
|
||||
Box,
|
||||
LogOut,
|
||||
SunMoon,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Package,
|
||||
Gift,
|
||||
Menu,
|
||||
X,
|
||||
Puzzle,
|
||||
Zap,
|
||||
FileUp,
|
||||
Send,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { useI18n, type MessageKey } from '@/i18n'
|
||||
import { buildBreadcrumbs, buildNavigation } from './main-layout/navigation'
|
||||
|
||||
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
|
||||
|
||||
@@ -498,6 +479,7 @@ const moduleStore = useModuleStore()
|
||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||
const { siteName, siteSubtitle } = useSiteInfo()
|
||||
const { success, error: showError } = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
@@ -523,7 +505,7 @@ const updateSupported = ref(true)
|
||||
const updateStrategy = ref('manual')
|
||||
const updateCapabilityMessage = ref<string | null>(null)
|
||||
const dockerUpdateCommand = ref<string | null>(null)
|
||||
const reconnectMessage = ref('等待服务恢复...')
|
||||
const reconnectMessage = ref(t('update.reconnect.waiting'))
|
||||
const rollbackAvailable = ref(false)
|
||||
const rollingBack = ref(false)
|
||||
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
|
||||
@@ -532,28 +514,33 @@ const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
||||
const preparedUpdateVersion = ref<string | null>(
|
||||
readSessionStorageItem('aether_prepared_update_version')
|
||||
)
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
const MANUAL_UPDATE_HINT = '当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。'
|
||||
const SOURCE_BUILD_UPDATE_HINT: MessageKey = 'update.error.sourceBuildUpdateHint'
|
||||
const SOURCE_BUILD_RELEASE_HINT: MessageKey = 'update.error.sourceBuildReleaseHint'
|
||||
const MANUAL_UPDATE_HINT: MessageKey = 'update.error.manualHint'
|
||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||
let updateStatusPollTimer: number | null = null
|
||||
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
|
||||
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
|
||||
const updateDialogTitle = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') {
|
||||
return updateSupported.value ? '切换版本' : '版本详情'
|
||||
return updateSupported.value ? t('update.title.selected') : t('update.title.selectedReadOnly')
|
||||
}
|
||||
return '发现新版本'
|
||||
return t('update.title.latest')
|
||||
})
|
||||
const updateDialogVersionLabel = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') {
|
||||
return updateSupported.value ? '目标版本' : '版本标签'
|
||||
return updateSupported.value ? t('update.version.target') : t('update.version.tag')
|
||||
}
|
||||
return '最新版本'
|
||||
return t('update.version.latest')
|
||||
})
|
||||
const updateDialogReleaseLinkLabel = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') return '查看标签页'
|
||||
return updateSupported.value ? '查看更新' : '查看发布'
|
||||
if (updateDialogMode.value === 'selected') return t('update.link.tag')
|
||||
return updateSupported.value ? t('update.link.update') : t('update.link.release')
|
||||
})
|
||||
const themeModeTitle = computed(() => {
|
||||
if (themeMode.value === 'system') return t('theme.system')
|
||||
if (themeMode.value === 'dark') return t('theme.dark')
|
||||
return t('theme.light')
|
||||
})
|
||||
|
||||
watch(systemUpdatePhase, (val) => {
|
||||
@@ -598,8 +585,10 @@ function removeSessionStorageItem(key: string) {
|
||||
}
|
||||
|
||||
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
|
||||
if (!status) return '正在下载更新包...'
|
||||
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
|
||||
if (!status) return t('update.progress.downloadPackage')
|
||||
const label = status.progress_label
|
||||
? t('update.progress.downloadingLabel', { label: status.progress_label })
|
||||
: formatUpdateTaskPhase(status.phase)
|
||||
const downloaded = status.downloaded_bytes
|
||||
const total = status.total_bytes
|
||||
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
|
||||
@@ -614,17 +603,17 @@ function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): stri
|
||||
function formatUpdateTaskPhase(phase: string): string {
|
||||
switch (phase) {
|
||||
case 'downloading':
|
||||
return '正在下载更新包'
|
||||
return t('update.progress.downloadingPackage')
|
||||
case 'downloading_checksum':
|
||||
return '正在下载校验文件'
|
||||
return t('update.progress.downloadingChecksum')
|
||||
case 'verifying':
|
||||
return '正在校验更新包'
|
||||
return t('update.progress.verifying')
|
||||
case 'extracting':
|
||||
return '正在解压更新包'
|
||||
return t('update.progress.extracting')
|
||||
case 'prepared':
|
||||
return '更新包已准备完成'
|
||||
return t('update.progress.prepared')
|
||||
default:
|
||||
return '正在准备更新'
|
||||
return t('update.progress.preparing')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,10 +639,10 @@ async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
|
||||
const status = updateTaskStatus.value
|
||||
if (status?.phase === 'prepared') return status
|
||||
if (status?.phase === 'failed') {
|
||||
throw new Error(status.error || '下载更新失败')
|
||||
throw new Error(status.error || t('update.error.downloadFailed'))
|
||||
}
|
||||
}
|
||||
throw new Error('下载更新超时')
|
||||
throw new Error(t('update.error.downloadTimeout'))
|
||||
}
|
||||
|
||||
function startUpdateStatusPolling() {
|
||||
@@ -737,8 +726,8 @@ function applyUpdateCapability(capability: SystemUpdateCapabilityResponse) {
|
||||
dockerUpdateCommand.value = capability.docker_update_command || null
|
||||
}
|
||||
|
||||
function updateUnsupportedMessage(fallback = MANUAL_UPDATE_HINT): string {
|
||||
return updateCapabilityMessage.value || fallback
|
||||
function updateUnsupportedMessage(fallback: MessageKey = MANUAL_UPDATE_HINT): string {
|
||||
return updateCapabilityMessage.value || t(fallback)
|
||||
}
|
||||
|
||||
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
||||
@@ -778,7 +767,7 @@ function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse
|
||||
has_update: !release.is_current,
|
||||
updatable: canSelfUpdate && !release.is_current && release.updatable,
|
||||
update_blocker: release.is_current
|
||||
? '当前已是这个版本'
|
||||
? t('update.error.alreadyCurrent')
|
||||
: !canSelfUpdate
|
||||
? updateUnsupportedMessage(SOURCE_BUILD_RELEASE_HINT)
|
||||
: release.update_blocker,
|
||||
@@ -807,8 +796,8 @@ async function handleApplySystemUpdate() {
|
||||
applyUpdateCapability(capability)
|
||||
if (!capability.supported) {
|
||||
showError(
|
||||
updateUnsupportedMessage('不支持在线自更新'),
|
||||
'不支持在线更新'
|
||||
updateUnsupportedMessage('update.error.unsupported'),
|
||||
t('update.error.unsupportedTitle')
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -817,8 +806,8 @@ async function handleApplySystemUpdate() {
|
||||
const targetStatus = updateInfo.value || versionStatus.value
|
||||
if (targetStatus?.has_update && targetStatus.updatable === false) {
|
||||
showError(
|
||||
targetStatus.update_blocker || '当前版本暂不支持在线更新',
|
||||
'无法在线更新'
|
||||
targetStatus.update_blocker || t('update.error.notUpdatable'),
|
||||
t('update.error.cannotUpdateOnline')
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -830,7 +819,7 @@ async function handleApplySystemUpdate() {
|
||||
const finalStatus = await waitForPreparedUpdate()
|
||||
preparedUpdateVersion.value = targetVersion
|
||||
systemUpdatePhase.value = 'restart'
|
||||
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
||||
success(finalStatus.output || result.message || t('update.success.prepared'))
|
||||
} finally {
|
||||
stopUpdateStatusPolling()
|
||||
void refreshUpdateTaskStatus()
|
||||
@@ -839,14 +828,14 @@ async function handleApplySystemUpdate() {
|
||||
}
|
||||
|
||||
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
|
||||
success(result.message || '一键重启已启动')
|
||||
success(result.message || t('update.success.restartStarted'))
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '服务正在重启...'
|
||||
reconnectMessage.value = t('update.reconnect.restarting')
|
||||
showUpdateDialog.value = true
|
||||
applyingSystemUpdate.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
||||
const fallback = systemUpdatePhase.value === 'download' ? t('update.error.downloadFailed') : t('update.error.restartFailed')
|
||||
showError(parseApiError(err, fallback))
|
||||
} finally {
|
||||
applyingSystemUpdate.value = false
|
||||
@@ -858,14 +847,14 @@ async function handleRollback() {
|
||||
rollingBack.value = true
|
||||
try {
|
||||
const result = await adminApi.rollbackSystemUpdate()
|
||||
success(result.message || '回滚已启动')
|
||||
success(result.message || t('update.success.rollbackStarted'))
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '正在回滚到上一版本...'
|
||||
reconnectMessage.value = t('update.reconnect.rollback')
|
||||
showUpdateDialog.value = true
|
||||
rollingBack.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '回滚失败'))
|
||||
showError(parseApiError(err, t('update.error.rollbackFailed')))
|
||||
} finally {
|
||||
rollingBack.value = false
|
||||
}
|
||||
@@ -877,20 +866,22 @@ async function pollHealthUntilReady() {
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (i < 3) {
|
||||
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
|
||||
reconnectMessage.value = i === 0
|
||||
? t('update.reconnect.restarting')
|
||||
: t('update.reconnect.restartingWithSeconds', { seconds: i * 2 })
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
continue
|
||||
}
|
||||
|
||||
const elapsed = i * 2
|
||||
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
|
||||
reconnectMessage.value = t('update.reconnect.waitingWithSeconds', { seconds: elapsed })
|
||||
try {
|
||||
const resp = await fetch('/_gateway/health', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (resp.ok) {
|
||||
reconnectMessage.value = '服务已恢复,正在刷新...'
|
||||
reconnectMessage.value = t('update.reconnect.ready')
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
window.location.replace(buildFreshReloadUrl())
|
||||
return
|
||||
@@ -904,7 +895,7 @@ async function pollHealthUntilReady() {
|
||||
try {
|
||||
const status = await adminApi.getUpdateStatus()
|
||||
if (status.phase === 'failed' && status.error) {
|
||||
reconnectMessage.value = `更新失败: ${status.error}`
|
||||
reconnectMessage.value = t('update.error.updateFailedWithReason', { reason: status.error })
|
||||
systemUpdatePhase.value = 'download'
|
||||
return
|
||||
}
|
||||
@@ -916,7 +907,7 @@ async function pollHealthUntilReady() {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
}
|
||||
|
||||
reconnectMessage.value = '等待超时,请手动刷新页面'
|
||||
reconnectMessage.value = t('update.reconnect.timeout')
|
||||
systemUpdatePhase.value = 'download'
|
||||
}
|
||||
|
||||
@@ -1039,7 +1030,7 @@ function renderRequiredAnnouncement(content: string): string {
|
||||
}
|
||||
|
||||
function formatRequiredAnnouncementDate(value: string): string {
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
return new Date(value).toLocaleString(locale.value)
|
||||
}
|
||||
|
||||
async function acknowledgeRequiredAnnouncement() {
|
||||
@@ -1111,188 +1102,28 @@ function prefetchNavigationItem(href: string) {
|
||||
prefetchAdminNavigationTarget(href)
|
||||
}
|
||||
|
||||
// Navigation Data
|
||||
const navigation = computed(() => {
|
||||
const baseNavigation = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ name: '仪表盘', href: '/dashboard', icon: Home },
|
||||
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '资源',
|
||||
items: [
|
||||
{ name: '模型目录', href: '/dashboard/models', icon: Box },
|
||||
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '账户',
|
||||
items: [
|
||||
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
|
||||
{ name: '套餐中心', href: '/dashboard/billing', icon: Package },
|
||||
...(moduleStore.isActive('referral') ? [{ name: '我的邀请', href: '/dashboard/referral', icon: Gift }] : []),
|
||||
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 系统菜单项(静态部分)
|
||||
const systemItems: { name: string; href: string; icon: LucideIcon }[] = [
|
||||
{ name: '公告管理', href: '/admin/announcements', icon: Megaphone },
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
]
|
||||
|
||||
// 动态添加已激活模块的菜单项
|
||||
// 图标映射
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Key,
|
||||
KeyRound,
|
||||
FileUp,
|
||||
Shield,
|
||||
Puzzle,
|
||||
Server,
|
||||
Send,
|
||||
SlidersHorizontal,
|
||||
CreditCard,
|
||||
Gift,
|
||||
}
|
||||
|
||||
const activeModuleItems = (group: string) =>
|
||||
Object.values(moduleStore.modules)
|
||||
.filter(m => m.active && m.admin_route && m.admin_menu_group === group)
|
||||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
|
||||
.map(m => ({
|
||||
name: m.display_name,
|
||||
href: m.admin_route ?? '',
|
||||
icon: iconMap[m.admin_menu_icon || ''] || Puzzle
|
||||
}))
|
||||
|
||||
systemItems.push(...activeModuleItems('system'))
|
||||
|
||||
// 模块管理和系统设置放在最后
|
||||
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
|
||||
systemItems.push({ name: '系统设置', href: '/admin/system', icon: Cog })
|
||||
|
||||
const adminNavigation = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
||||
{ name: '运维总览', href: '/admin/operations', icon: Activity },
|
||||
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
|
||||
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
|
||||
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '管理',
|
||||
items: [
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '调度策略', href: '/admin/routing', icon: SlidersHorizontal },
|
||||
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
|
||||
{ name: '套餐管理', href: '/admin/billing-plans', icon: Package },
|
||||
...activeModuleItems('management'),
|
||||
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
|
||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: systemItems
|
||||
}
|
||||
]
|
||||
|
||||
return authStore.canAccessAdmin ? adminNavigation : baseNavigation
|
||||
return buildNavigation({
|
||||
canAccessAdmin: authStore.canAccessAdmin,
|
||||
modules: moduleStore.modules,
|
||||
isModuleActive: moduleStore.isActive,
|
||||
t,
|
||||
})
|
||||
})
|
||||
|
||||
const currentRoleLabel = computed(() => {
|
||||
if (authStore.isAdmin) return '管理员'
|
||||
if (authStore.isAuditAdmin) return '审计管理员'
|
||||
return '用户'
|
||||
if (authStore.isAdmin) return t('auth.role.admin')
|
||||
if (authStore.isAuditAdmin) return t('auth.role.auditAdmin')
|
||||
return t('auth.role.user')
|
||||
})
|
||||
|
||||
// Breadcrumbs
|
||||
interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const breadcrumbs = computed((): BreadcrumbItem[] => {
|
||||
// Special case: personal settings page accessed by admin
|
||||
if (route.path === '/dashboard/settings') {
|
||||
return [
|
||||
{ label: '账户' },
|
||||
{ label: '个人设置' }
|
||||
]
|
||||
}
|
||||
|
||||
// Special case: module config pages (e.g., /admin/ldap)
|
||||
if (route.meta?.module) {
|
||||
const moduleName = route.meta.module as string
|
||||
const moduleStatus = moduleStore.modules[moduleName]
|
||||
const displayName = moduleStatus?.display_name || moduleName
|
||||
return [
|
||||
{ label: '系统' },
|
||||
{ label: '模块管理', href: '/admin/modules' },
|
||||
{ label: displayName }
|
||||
]
|
||||
}
|
||||
|
||||
// Special case: built-in tools under module management
|
||||
if (BUILTIN_TOOL_BREADCRUMBS[route.path]) {
|
||||
return [
|
||||
{ label: '系统' },
|
||||
{ label: '模块管理', href: '/admin/modules' },
|
||||
{ label: BUILTIN_TOOL_BREADCRUMBS[route.path] }
|
||||
]
|
||||
}
|
||||
|
||||
// Special case: routing strategy detail pages
|
||||
if (route.path.startsWith('/admin/routing/') && route.path !== '/admin/routing') {
|
||||
return [
|
||||
{ label: '管理' },
|
||||
{ label: '调度策略', href: '/admin/routing' },
|
||||
{
|
||||
label: route.name === 'RoutingProfileCreate'
|
||||
? '新建调度策略'
|
||||
: '调度策略配置'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Find section and page from navigation
|
||||
for (const group of navigation.value) {
|
||||
const activeItem = group.items.find(item => isNavActive(item.href))
|
||||
if (activeItem) {
|
||||
return [
|
||||
{ label: group.title || '' },
|
||||
{ label: activeItem.name }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: module pages not in navigation (module not active)
|
||||
// Check if current path matches a module's admin_route
|
||||
const currentModule = Object.values(moduleStore.modules).find(
|
||||
m => m.admin_route && route.path === m.admin_route
|
||||
)
|
||||
if (currentModule) {
|
||||
return [
|
||||
{ label: '模块管理', href: '/admin/modules' },
|
||||
{ label: currentModule.display_name }
|
||||
]
|
||||
}
|
||||
|
||||
return [{ label: '仪表盘' }]
|
||||
})
|
||||
const breadcrumbs = computed(() => buildBreadcrumbs({
|
||||
route,
|
||||
navigation: navigation.value,
|
||||
modules: moduleStore.modules,
|
||||
isNavActive,
|
||||
t,
|
||||
}))
|
||||
|
||||
// Styling Classes (Editorial)
|
||||
const sidebarClasses = computed(() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
import { buildBreadcrumbs, buildNavigation } from '@/layouts/main-layout/navigation'
|
||||
import type { MessageKey } from '@/i18n'
|
||||
|
||||
const translate = (key: MessageKey) => `tx:${key}`
|
||||
|
||||
function route(path: string, name?: string, meta: Record<string, unknown> = {}): RouteLocationNormalizedLoaded {
|
||||
return {
|
||||
path,
|
||||
fullPath: path,
|
||||
query: {},
|
||||
hash: '',
|
||||
name,
|
||||
params: {},
|
||||
matched: [],
|
||||
meta,
|
||||
redirectedFrom: undefined,
|
||||
} as RouteLocationNormalizedLoaded
|
||||
}
|
||||
|
||||
describe('main layout navigation builder', () => {
|
||||
it('builds user navigation from translation keys and active modules', () => {
|
||||
const navigation = buildNavigation({
|
||||
canAccessAdmin: false,
|
||||
modules: {},
|
||||
isModuleActive: (name) => name === 'referral',
|
||||
t: translate,
|
||||
})
|
||||
|
||||
expect(navigation.map(group => group.title)).toEqual([
|
||||
'tx:nav.group.overview',
|
||||
'tx:nav.group.resources',
|
||||
'tx:nav.group.account',
|
||||
])
|
||||
expect(navigation.flatMap(group => group.items.map(item => item.name))).toContain('tx:nav.myReferral')
|
||||
})
|
||||
|
||||
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
|
||||
const navigation = buildNavigation({
|
||||
canAccessAdmin: true,
|
||||
modules: {
|
||||
first: {
|
||||
active: true,
|
||||
admin_route: '/admin/first',
|
||||
admin_menu_group: 'management',
|
||||
admin_menu_order: 2,
|
||||
admin_menu_icon: 'Gift',
|
||||
display_name: 'First module',
|
||||
},
|
||||
second: {
|
||||
active: true,
|
||||
admin_route: '/admin/second',
|
||||
admin_menu_group: 'management',
|
||||
admin_menu_order: 1,
|
||||
admin_menu_icon: 'Key',
|
||||
display_name: 'Second module',
|
||||
},
|
||||
},
|
||||
isModuleActive: () => false,
|
||||
t: translate,
|
||||
})
|
||||
|
||||
const managementItems = navigation.find(group => group.title === 'tx:nav.group.management')?.items ?? []
|
||||
expect(managementItems.map(item => item.name)).toEqual(expect.arrayContaining(['Second module', 'First module']))
|
||||
expect(managementItems.findIndex(item => item.name === 'Second module')).toBeLessThan(
|
||||
managementItems.findIndex(item => item.name === 'First module')
|
||||
)
|
||||
})
|
||||
|
||||
it('builds translated breadcrumbs for settings and routing detail pages', () => {
|
||||
const navigation = buildNavigation({
|
||||
canAccessAdmin: true,
|
||||
modules: {},
|
||||
isModuleActive: () => false,
|
||||
t: translate,
|
||||
})
|
||||
|
||||
expect(buildBreadcrumbs({
|
||||
route: route('/dashboard/settings'),
|
||||
navigation,
|
||||
modules: {},
|
||||
isNavActive: () => false,
|
||||
t: translate,
|
||||
})).toEqual([
|
||||
{ label: 'tx:nav.group.account' },
|
||||
{ label: 'tx:breadcrumb.personalSettings' },
|
||||
])
|
||||
|
||||
expect(buildBreadcrumbs({
|
||||
route: route('/admin/routing/new', 'RoutingProfileCreate'),
|
||||
navigation,
|
||||
modules: {},
|
||||
isNavActive: href => href === '/admin/routing',
|
||||
t: translate,
|
||||
})).toEqual([
|
||||
{ label: 'tx:nav.group.management' },
|
||||
{ label: 'tx:nav.routing', href: '/admin/routing' },
|
||||
{ label: 'tx:breadcrumb.routingCreate' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { LucideIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Box,
|
||||
Cog,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileUp,
|
||||
FolderTree,
|
||||
Gift,
|
||||
Gauge,
|
||||
Home,
|
||||
Key,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Package,
|
||||
Puzzle,
|
||||
Send,
|
||||
Server,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
Wallet,
|
||||
Zap,
|
||||
Megaphone,
|
||||
} from 'lucide-vue-next'
|
||||
import type { NavigationGroup } from '@/components/layout/SidebarNav.vue'
|
||||
import type { ModuleStatus } from '@/api/modules'
|
||||
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||
import type { MessageKey } from '@/i18n'
|
||||
|
||||
type ModuleRecord = Record<string, ModuleStatus>
|
||||
type Translate = (key: MessageKey) => string
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
type NavItem = NavigationGroup['items'][number]
|
||||
|
||||
const moduleIconMap: Record<string, LucideIcon> = {
|
||||
Key,
|
||||
KeyRound,
|
||||
FileUp,
|
||||
Shield,
|
||||
Puzzle,
|
||||
Server,
|
||||
Send,
|
||||
SlidersHorizontal,
|
||||
CreditCard,
|
||||
Gift,
|
||||
}
|
||||
|
||||
function activeModuleItems(modules: ModuleRecord, group: string): NavItem[] {
|
||||
return Object.values(modules)
|
||||
.filter(m => m.active && m.admin_route && m.admin_menu_group === group)
|
||||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
|
||||
.map(m => ({
|
||||
name: m.display_name,
|
||||
href: m.admin_route ?? '',
|
||||
icon: moduleIconMap[m.admin_menu_icon || ''] || Puzzle
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildNavigation(options: {
|
||||
canAccessAdmin: boolean
|
||||
modules: ModuleRecord
|
||||
isModuleActive: (name: string) => boolean
|
||||
t?: Translate
|
||||
}): NavigationGroup[] {
|
||||
const { canAccessAdmin, modules, isModuleActive } = options
|
||||
const t = options.t ?? ((key: MessageKey) => key)
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
return [
|
||||
{
|
||||
title: t('nav.group.overview'),
|
||||
items: [
|
||||
{ name: t('nav.dashboard'), href: '/dashboard', icon: Home },
|
||||
{ name: t('nav.healthMonitor'), href: '/dashboard/endpoint-status', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('nav.group.resources'),
|
||||
items: [
|
||||
{ name: t('nav.modelCatalog'), href: '/dashboard/models', icon: Box },
|
||||
{ name: t('nav.apiKeys'), href: '/dashboard/api-keys', icon: Key },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('nav.group.account'),
|
||||
items: [
|
||||
{ name: t('nav.walletCenter'), href: '/dashboard/wallet', icon: Wallet },
|
||||
{ name: t('nav.billingCenter'), href: '/dashboard/billing', icon: Package },
|
||||
...(isModuleActive('referral') ? [{ name: t('nav.myReferral'), href: '/dashboard/referral', icon: Gift }] : []),
|
||||
{ name: t('nav.usageStats'), href: '/dashboard/usage', icon: BarChart3 },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const systemItems: NavItem[] = [
|
||||
{ name: t('nav.announcements'), href: '/admin/announcements', icon: Megaphone },
|
||||
{ name: t('nav.cacheMonitoring'), href: '/admin/cache-monitoring', icon: Gauge },
|
||||
...activeModuleItems(modules, 'system'),
|
||||
{ name: t('nav.moduleManagement'), href: '/admin/modules', icon: Puzzle },
|
||||
{ name: t('nav.systemSettings'), href: '/admin/system', icon: Cog },
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('nav.group.overview'),
|
||||
items: [
|
||||
{ name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home },
|
||||
{ name: t('nav.operations'), href: '/admin/operations', icon: Activity },
|
||||
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 },
|
||||
{ name: t('nav.costAnalysis'), href: '/admin/cost-analysis', icon: Gauge },
|
||||
{ name: t('nav.performanceAnalysis'), href: '/admin/performance-analysis', icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('nav.group.management'),
|
||||
items: [
|
||||
{ name: t('nav.userManagement'), href: '/admin/users', icon: Users },
|
||||
{ name: t('nav.providers'), href: '/admin/providers', icon: FolderTree },
|
||||
{ name: t('nav.modelManagement'), href: '/admin/models', icon: Layers },
|
||||
{ name: t('nav.routing'), href: '/admin/routing', icon: SlidersHorizontal },
|
||||
{ name: t('nav.pool'), href: '/admin/pool', icon: Database },
|
||||
{ name: t('nav.standaloneKeys'), href: '/admin/keys', icon: Key },
|
||||
{ name: t('nav.walletManagement'), href: '/admin/wallets', icon: Wallet },
|
||||
{ name: t('nav.billingManagement'), href: '/admin/billing-plans', icon: Package },
|
||||
...activeModuleItems(modules, 'management'),
|
||||
{ name: t('nav.asyncTasks'), href: '/admin/async-tasks', icon: Zap },
|
||||
{ name: t('nav.usageRecords'), href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('nav.group.system'),
|
||||
items: systemItems
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildBreadcrumbs(options: {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
navigation: NavigationGroup[]
|
||||
modules: ModuleRecord
|
||||
isNavActive: (href: string) => boolean
|
||||
t?: Translate
|
||||
}): BreadcrumbItem[] {
|
||||
const { route, navigation, modules, isNavActive } = options
|
||||
const t = options.t ?? ((key: MessageKey) => key)
|
||||
|
||||
if (route.path === '/dashboard/settings') {
|
||||
return [
|
||||
{ label: t('nav.group.account') },
|
||||
{ label: t('breadcrumb.personalSettings') }
|
||||
]
|
||||
}
|
||||
|
||||
if (route.meta?.module) {
|
||||
const moduleName = route.meta.module as string
|
||||
const moduleStatus = modules[moduleName]
|
||||
const displayName = moduleStatus?.display_name || moduleName
|
||||
return [
|
||||
{ label: t('nav.group.system') },
|
||||
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
|
||||
{ label: displayName }
|
||||
]
|
||||
}
|
||||
|
||||
if (BUILTIN_TOOL_BREADCRUMBS[route.path]) {
|
||||
return [
|
||||
{ label: t('nav.group.system') },
|
||||
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
|
||||
{ label: BUILTIN_TOOL_BREADCRUMBS[route.path] }
|
||||
]
|
||||
}
|
||||
|
||||
if (route.path.startsWith('/admin/routing/') && route.path !== '/admin/routing') {
|
||||
return [
|
||||
{ label: t('nav.group.management') },
|
||||
{ label: t('nav.routing'), href: '/admin/routing' },
|
||||
{
|
||||
label: route.name === 'RoutingProfileCreate'
|
||||
? t('breadcrumb.routingCreate')
|
||||
: t('breadcrumb.routingConfig')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
for (const group of navigation) {
|
||||
const activeItem = group.items.find(item => isNavActive(item.href))
|
||||
if (activeItem) {
|
||||
return [
|
||||
{ label: group.title || '' },
|
||||
{ label: activeItem.name }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentModule = Object.values(modules).find(
|
||||
m => m.admin_route && route.path === m.admin_route
|
||||
)
|
||||
if (currentModule) {
|
||||
return [
|
||||
{ label: t('nav.moduleManagement'), href: '/admin/modules' },
|
||||
{ label: currentModule.display_name }
|
||||
]
|
||||
}
|
||||
|
||||
return [{ label: t('nav.dashboard') }]
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import router from './router'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { preloadCriticalModules } from './utils/importRetry'
|
||||
import { createI18n } from './i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const i18n = createI18n()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
|
||||
// 预加载关键模块
|
||||
@@ -23,4 +26,4 @@ app.config.errorHandler = (err: unknown, _instance, info) => {
|
||||
// 模块加载错误处理已移至 App.vue 的统一处理器中
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { resolveHomeRedirect } from '@/router/guards/homeGuard'
|
||||
|
||||
function route(path: string, query: Record<string, string> = {}): RouteLocationNormalized {
|
||||
return {
|
||||
path,
|
||||
fullPath: path,
|
||||
query,
|
||||
hash: '',
|
||||
name: undefined,
|
||||
params: {},
|
||||
matched: [],
|
||||
meta: {},
|
||||
redirectedFrom: undefined,
|
||||
} as RouteLocationNormalized
|
||||
}
|
||||
|
||||
function authStore(options: { isAuthenticated: boolean; canAccessAdmin?: boolean }) {
|
||||
return {
|
||||
isAuthenticated: options.isAuthenticated,
|
||||
canAccessAdmin: options.canAccessAdmin ?? false,
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('resolveHomeRedirect', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('ignores non-home routes and unauthenticated home visits', () => {
|
||||
expect(resolveHomeRedirect(route('/dashboard'), route('/'), authStore({ isAuthenticated: true }))).toBeNull()
|
||||
expect(resolveHomeRedirect(route('/'), route('/login'), authStore({ isAuthenticated: false }))).toBeNull()
|
||||
})
|
||||
|
||||
it('allows authenticated users to return to the public home from the app shell', () => {
|
||||
expect(resolveHomeRedirect(route('/'), route('/dashboard'), authStore({ isAuthenticated: true }))).toBe('')
|
||||
expect(resolveHomeRedirect(route('/', { returnTo: '/guide' }), route('/external'), authStore({ isAuthenticated: true }))).toBe('')
|
||||
})
|
||||
|
||||
it('consumes stored redirect path before falling back to dashboard defaults', () => {
|
||||
sessionStorage.setItem('redirectPath', '/dashboard/api-keys')
|
||||
|
||||
expect(resolveHomeRedirect(route('/'), route('/external'), authStore({ isAuthenticated: true }))).toBe('/dashboard/api-keys')
|
||||
expect(sessionStorage.getItem('redirectPath')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes authenticated users to the correct dashboard by role', () => {
|
||||
expect(resolveHomeRedirect(route('/'), route('/external'), authStore({ isAuthenticated: true }))).toBe('/dashboard')
|
||||
expect(resolveHomeRedirect(route('/'), route('/external'), authStore({ isAuthenticated: true, canAccessAdmin: true }))).toBe('/admin/dashboard')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { importWithRetry } from '@/utils/importRetry'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
ensureUserLoaded,
|
||||
@@ -10,382 +8,7 @@ import {
|
||||
checkAdminAccess,
|
||||
checkModuleAccess
|
||||
} from './guards'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'RegisterEntry',
|
||||
component: () => importWithRetry(() => import('@/views/public/Home.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
component: () => importWithRetry(() => import('@/views/public/PrivacyPolicy.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
|
||||
{
|
||||
path: '/guide',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/GuideLayout.vue')),
|
||||
meta: { requiresAuth: false },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'GuideOverview',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/Overview.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'architecture',
|
||||
name: 'GuideArchitecture',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/ArchitectureGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'concepts',
|
||||
name: 'GuideConcepts',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/ConceptsGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'strategy',
|
||||
name: 'GuideStrategy',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/StrategyGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
name: 'GuideAdvanced',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/AdvancedGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'GuideFaq',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/GuideFaq.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'GuideModules',
|
||||
component: () => importWithRetry(() => import('@/views/public/guide/ModulesGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/logo-demo',
|
||||
name: 'LogoColorDemo',
|
||||
component: () => importWithRetry(() => import('@/views/public/LogoColorDemo.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'AuthCallback',
|
||||
component: () => importWithRetry(() => import('@/views/public/AuthCallback.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => importWithRetry(() => import('@/layouts/MainLayout.vue')),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Dashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'api-keys',
|
||||
name: 'MyApiKeys',
|
||||
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'ManagementTokens',
|
||||
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')),
|
||||
meta: { module: 'management_tokens' }
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'Announcements',
|
||||
component: () => importWithRetry(() => import('@/views/user/Announcements.vue'))
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
name: 'MyUsage',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'endpoint-status',
|
||||
name: 'EndpointStatus',
|
||||
component: () => importWithRetry(() => import('@/views/shared/HealthMonitor.vue'))
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => importWithRetry(() => import('@/views/user/Settings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
name: 'WalletCenter',
|
||||
component: () => importWithRetry(() => import('@/views/user/WalletCenter.vue'))
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
name: 'BillingPlans',
|
||||
component: () => importWithRetry(() => import('@/views/user/BillingPlans.vue'))
|
||||
},
|
||||
{
|
||||
path: 'referral',
|
||||
name: 'ReferralCenter',
|
||||
component: () => importWithRetry(() => import('@/views/user/ReferralCenter.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelCatalog',
|
||||
component: () => importWithRetry(() => import('@/views/user/ModelCatalog.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'UserAsyncTasks',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => importWithRetry(() => import('@/layouts/MainLayout.vue')),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Dashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'operations',
|
||||
name: 'AdminOperationsDashboard',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AdminOperationsDashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => importWithRetry(() => import('@/views/admin/Users.vue'))
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'ApiKeys',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallets',
|
||||
name: 'WalletsManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/WalletsManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'payment-gateways',
|
||||
name: 'PaymentGatewaySettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PaymentGatewaySettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'billing-plans',
|
||||
name: 'BillingPlansManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/BillingPlansManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'referrals',
|
||||
name: 'ReferralManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ReferralManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'AdminManagementTokens',
|
||||
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')),
|
||||
meta: { module: 'management_tokens' }
|
||||
},
|
||||
{
|
||||
path: 'providers',
|
||||
name: 'ProviderManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ProviderManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'pool',
|
||||
name: 'PoolManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PoolManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing',
|
||||
name: 'RoutingProfiles',
|
||||
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing/new',
|
||||
name: 'RoutingProfileCreate',
|
||||
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing/:groupId',
|
||||
name: 'RoutingProfileDetail',
|
||||
component: () => importWithRetry(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'health-monitor',
|
||||
name: 'HealthMonitor',
|
||||
component: () => importWithRetry(() => import('@/views/shared/HealthMonitor.vue'))
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
name: 'Usage',
|
||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'user-stats',
|
||||
name: 'UserStats',
|
||||
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cost-analysis',
|
||||
name: 'CostAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'performance-analysis',
|
||||
name: 'PerformanceAnalysis',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'ModuleManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'model-directives',
|
||||
name: 'ModelDirectivesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
|
||||
meta: { module: 'model_directives' }
|
||||
},
|
||||
{
|
||||
path: 'modules/chat-pii-redaction',
|
||||
name: 'ChatPiiRedactionModule',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
||||
meta: { module: 'chat_pii_redaction' }
|
||||
},
|
||||
{
|
||||
path: 'modules/s3-backup',
|
||||
name: 'S3BackupSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/S3BackupSettings.vue')),
|
||||
meta: { module: 's3_backup' }
|
||||
},
|
||||
{
|
||||
path: 'modules/important-notification',
|
||||
redirect: '/admin/notification-service'
|
||||
},
|
||||
{
|
||||
path: 'notification-service',
|
||||
name: 'ImportantNotificationModule',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
|
||||
meta: { module: 'important_notification' }
|
||||
},
|
||||
{
|
||||
path: 'server-chan',
|
||||
redirect: '/admin/modules/server-chan'
|
||||
},
|
||||
{
|
||||
path: 'modules/server-chan',
|
||||
name: 'ServerChanSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
|
||||
meta: { module: 'server_chan_push' }
|
||||
},
|
||||
{
|
||||
path: 'bark',
|
||||
redirect: '/admin/modules/bark'
|
||||
},
|
||||
{
|
||||
path: 'modules/bark',
|
||||
name: 'BarkSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/BarkSettings.vue')),
|
||||
meta: { module: 'bark_push' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'ldap',
|
||||
name: 'LdapSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue')),
|
||||
meta: { module: 'ldap' }
|
||||
},
|
||||
{
|
||||
path: 'oauth',
|
||||
name: 'OAuthSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/OAuthSettings.vue')),
|
||||
meta: { module: 'oauth' }
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AuditLogs.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cache-monitoring',
|
||||
name: 'CacheMonitoring',
|
||||
component: () => importWithRetry(() => import('@/views/admin/CacheMonitoring.vue'))
|
||||
},
|
||||
{
|
||||
path: 'ip-security',
|
||||
name: 'IPSecurity',
|
||||
component: () => importWithRetry(() => import('@/views/admin/IPSecurity.vue'))
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'AnnouncementManagement',
|
||||
component: () => importWithRetry(() => import('@/views/user/Announcements.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'AsyncTasks',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
},
|
||||
{
|
||||
path: 'proxy-nodes',
|
||||
name: 'ProxyNodes',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ProxyNodes.vue')),
|
||||
meta: { module: 'proxy_nodes' }
|
||||
},
|
||||
{
|
||||
path: 'gemini-files',
|
||||
name: 'GeminiFilesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
||||
},
|
||||
// 保留旧路由兼容性
|
||||
{
|
||||
path: 'video-tasks',
|
||||
redirect: '/admin/async-tasks'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
import { routes } from './routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { view } from './helpers'
|
||||
|
||||
export const adminRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
component: view(() => import('@/layouts/MainLayout.vue')),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: view(() => import('@/views/shared/Dashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'operations',
|
||||
name: 'AdminOperationsDashboard',
|
||||
component: view(() => import('@/views/admin/AdminOperationsDashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: view(() => import('@/views/admin/Users.vue'))
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'ApiKeys',
|
||||
component: view(() => import('@/views/admin/ApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallets',
|
||||
name: 'WalletsManagement',
|
||||
component: view(() => import('@/views/admin/WalletsManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'payment-gateways',
|
||||
name: 'PaymentGatewaySettings',
|
||||
component: view(() => import('@/views/admin/PaymentGatewaySettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'billing-plans',
|
||||
name: 'BillingPlansManagement',
|
||||
component: view(() => import('@/views/admin/BillingPlansManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'referrals',
|
||||
name: 'ReferralManagement',
|
||||
component: view(() => import('@/views/admin/ReferralManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'AdminManagementTokens',
|
||||
component: view(() => import('@/views/user/ManagementTokens.vue')),
|
||||
meta: { module: 'management_tokens' }
|
||||
},
|
||||
{
|
||||
path: 'providers',
|
||||
name: 'ProviderManagement',
|
||||
component: view(() => import('@/views/admin/ProviderManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'pool',
|
||||
name: 'PoolManagement',
|
||||
component: view(() => import('@/views/admin/PoolManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelManagement',
|
||||
component: view(() => import('@/views/admin/ModelManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing',
|
||||
name: 'RoutingProfiles',
|
||||
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing/new',
|
||||
name: 'RoutingProfileCreate',
|
||||
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'routing/:groupId',
|
||||
name: 'RoutingProfileDetail',
|
||||
component: view(() => import('@/views/admin/RoutingProfiles.vue'))
|
||||
},
|
||||
{
|
||||
path: 'health-monitor',
|
||||
name: 'HealthMonitor',
|
||||
component: view(() => import('@/views/shared/HealthMonitor.vue'))
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
name: 'Usage',
|
||||
component: view(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'user-stats',
|
||||
name: 'UserStats',
|
||||
component: view(() => import('@/views/admin/UserStats.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cost-analysis',
|
||||
name: 'CostAnalysis',
|
||||
component: view(() => import('@/views/admin/CostAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'performance-analysis',
|
||||
name: 'PerformanceAnalysis',
|
||||
component: view(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'SystemSettings',
|
||||
component: view(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'ModuleManagement',
|
||||
component: view(() => import('@/views/admin/ModuleManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'model-directives',
|
||||
name: 'ModelDirectivesManagement',
|
||||
component: view(() => import('@/views/admin/ModelDirectivesManagement.vue')),
|
||||
meta: { module: 'model_directives' }
|
||||
},
|
||||
{
|
||||
path: 'modules/chat-pii-redaction',
|
||||
name: 'ChatPiiRedactionModule',
|
||||
component: view(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
||||
meta: { module: 'chat_pii_redaction' }
|
||||
},
|
||||
{
|
||||
path: 'modules/s3-backup',
|
||||
name: 'S3BackupSettings',
|
||||
component: view(() => import('@/views/admin/modules/S3BackupSettings.vue')),
|
||||
meta: { module: 's3_backup' }
|
||||
},
|
||||
{
|
||||
path: 'modules/important-notification',
|
||||
redirect: '/admin/notification-service'
|
||||
},
|
||||
{
|
||||
path: 'notification-service',
|
||||
name: 'ImportantNotificationModule',
|
||||
component: view(() => import('@/views/admin/modules/ImportantNotification.vue')),
|
||||
meta: { module: 'important_notification' }
|
||||
},
|
||||
{
|
||||
path: 'server-chan',
|
||||
redirect: '/admin/modules/server-chan'
|
||||
},
|
||||
{
|
||||
path: 'modules/server-chan',
|
||||
name: 'ServerChanSettings',
|
||||
component: view(() => import('@/views/admin/modules/ServerChanSettings.vue')),
|
||||
meta: { module: 'server_chan_push' }
|
||||
},
|
||||
{
|
||||
path: 'bark',
|
||||
redirect: '/admin/modules/bark'
|
||||
},
|
||||
{
|
||||
path: 'modules/bark',
|
||||
name: 'BarkSettings',
|
||||
component: view(() => import('@/views/admin/modules/BarkSettings.vue')),
|
||||
meta: { module: 'bark_push' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: view(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'ldap',
|
||||
name: 'LdapSettings',
|
||||
component: view(() => import('@/views/admin/LdapSettings.vue')),
|
||||
meta: { module: 'ldap' }
|
||||
},
|
||||
{
|
||||
path: 'oauth',
|
||||
name: 'OAuthSettings',
|
||||
component: view(() => import('@/views/admin/OAuthSettings.vue')),
|
||||
meta: { module: 'oauth' }
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
component: view(() => import('@/views/admin/AuditLogs.vue'))
|
||||
},
|
||||
{
|
||||
path: 'cache-monitoring',
|
||||
name: 'CacheMonitoring',
|
||||
component: view(() => import('@/views/admin/CacheMonitoring.vue'))
|
||||
},
|
||||
{
|
||||
path: 'ip-security',
|
||||
name: 'IPSecurity',
|
||||
component: view(() => import('@/views/admin/IPSecurity.vue'))
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'AnnouncementManagement',
|
||||
component: view(() => import('@/views/user/Announcements.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'AsyncTasks',
|
||||
component: view(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
},
|
||||
{
|
||||
path: 'proxy-nodes',
|
||||
name: 'ProxyNodes',
|
||||
component: view(() => import('@/views/admin/ProxyNodes.vue')),
|
||||
meta: { module: 'proxy_nodes' }
|
||||
},
|
||||
{
|
||||
path: 'gemini-files',
|
||||
name: 'GeminiFilesManagement',
|
||||
component: view(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'video-tasks',
|
||||
redirect: '/admin/async-tasks'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { view } from './helpers'
|
||||
|
||||
export const dashboardRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: view(() => import('@/layouts/MainLayout.vue')),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: view(() => import('@/views/shared/Dashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'api-keys',
|
||||
name: 'MyApiKeys',
|
||||
component: view(() => import('@/views/user/MyApiKeys.vue'))
|
||||
},
|
||||
{
|
||||
path: 'management-tokens',
|
||||
name: 'ManagementTokens',
|
||||
component: view(() => import('@/views/user/ManagementTokens.vue')),
|
||||
meta: { module: 'management_tokens' }
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'Announcements',
|
||||
component: view(() => import('@/views/user/Announcements.vue'))
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
name: 'MyUsage',
|
||||
component: view(() => import('@/views/shared/Usage.vue'))
|
||||
},
|
||||
{
|
||||
path: 'endpoint-status',
|
||||
name: 'EndpointStatus',
|
||||
component: view(() => import('@/views/shared/HealthMonitor.vue'))
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: view(() => import('@/views/user/Settings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
name: 'WalletCenter',
|
||||
component: view(() => import('@/views/user/WalletCenter.vue'))
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
name: 'BillingPlans',
|
||||
component: view(() => import('@/views/user/BillingPlans.vue'))
|
||||
},
|
||||
{
|
||||
path: 'referral',
|
||||
name: 'ReferralCenter',
|
||||
component: view(() => import('@/views/user/ReferralCenter.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelCatalog',
|
||||
component: view(() => import('@/views/user/ModelCatalog.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'UserAsyncTasks',
|
||||
component: view(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
import { importWithRetry } from '@/utils/importRetry'
|
||||
|
||||
export const view = <T>(loader: () => Promise<T>) => () => importWithRetry(loader)
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { adminRoutes } from './admin'
|
||||
import { dashboardRoutes } from './dashboard'
|
||||
import { publicRoutes } from './public'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
...publicRoutes,
|
||||
...dashboardRoutes,
|
||||
...adminRoutes,
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { view } from './helpers'
|
||||
|
||||
export const publicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: view(() => import('@/views/public/Home.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'RegisterEntry',
|
||||
component: view(() => import('@/views/public/Home.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
component: view(() => import('@/views/public/PrivacyPolicy.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/guide',
|
||||
component: view(() => import('@/views/public/guide/GuideLayout.vue')),
|
||||
meta: { requiresAuth: false },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'GuideOverview',
|
||||
component: view(() => import('@/views/public/guide/Overview.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'architecture',
|
||||
name: 'GuideArchitecture',
|
||||
component: view(() => import('@/views/public/guide/ArchitectureGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'concepts',
|
||||
name: 'GuideConcepts',
|
||||
component: view(() => import('@/views/public/guide/ConceptsGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'strategy',
|
||||
name: 'GuideStrategy',
|
||||
component: view(() => import('@/views/public/guide/StrategyGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
name: 'GuideAdvanced',
|
||||
component: view(() => import('@/views/public/guide/AdvancedGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'GuideFaq',
|
||||
component: view(() => import('@/views/public/guide/GuideFaq.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'GuideModules',
|
||||
component: view(() => import('@/views/public/guide/ModulesGuide.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/logo-demo',
|
||||
name: 'LogoColorDemo',
|
||||
component: view(() => import('@/views/public/LogoColorDemo.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'AuthCallback',
|
||||
component: view(() => import('@/views/public/AuthCallback.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import { beforeEach } from 'vitest'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const store = new Map<string, string>()
|
||||
|
||||
@@ -41,3 +43,10 @@ function installStorage(name: 'localStorage' | 'sessionStorage') {
|
||||
|
||||
installStorage('localStorage')
|
||||
installStorage('sessionStorage')
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
const { setI18nLocale } = await import('@/i18n')
|
||||
setI18nLocale('zh-CN')
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
|
||||
const COMPACT_NUMBER_UNITS = [
|
||||
{ value: 1_000_000_000_000, suffix: 'T' },
|
||||
{ value: 1_000_000_000, suffix: 'B' },
|
||||
@@ -145,14 +147,14 @@ export function formatNumber(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null) {
|
||||
return '0'
|
||||
}
|
||||
return num.toLocaleString('zh-CN')
|
||||
return num.toLocaleString(getI18nLocale())
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
export function formatDate(dateString: string | undefined | null): string {
|
||||
if (!dateString) return '未知'
|
||||
if (!dateString) return getI18nLocale() === 'en-US' ? 'Unknown' : '未知'
|
||||
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
return new Date(dateString).toLocaleDateString(getI18nLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -177,6 +179,15 @@ export function formatModelPrice(price: number | undefined | null): string {
|
||||
|
||||
// Billing type formatting
|
||||
export function formatBillingType(type: string | undefined | null): string {
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishTypeMap: Record<string, string> = {
|
||||
'pay_as_you_go': 'Pay as you go',
|
||||
'monthly_quota': 'Monthly quota',
|
||||
'free_tier': 'Free tier',
|
||||
}
|
||||
return englishTypeMap[type || ''] || type || 'Pay as you go'
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
'pay_as_you_go': '按量付费',
|
||||
'monthly_quota': '月卡配额',
|
||||
@@ -198,13 +209,14 @@ export function formatUsageCount(count: number): string {
|
||||
|
||||
// Format remaining time from unix timestamp
|
||||
export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string {
|
||||
if (!expireAt) return '未知'
|
||||
const isEnglish = getI18nLocale() === 'en-US'
|
||||
if (!expireAt) return isEnglish ? 'Unknown' : '未知'
|
||||
const remaining = expireAt - currentTime
|
||||
if (remaining <= 0) return '已过期'
|
||||
if (remaining <= 0) return isEnglish ? 'Expired' : '已过期'
|
||||
|
||||
const minutes = Math.floor(remaining / 60)
|
||||
const seconds = Math.floor(remaining % 60)
|
||||
return `${minutes}分${seconds}秒`
|
||||
return isEnglish ? `${minutes}m ${seconds}s` : `${minutes}分${seconds}秒`
|
||||
}
|
||||
|
||||
// Cache hit rate formatting
|
||||
@@ -215,14 +227,14 @@ export function formatHitRate(rate: number | undefined): string {
|
||||
|
||||
// Rate limit formatting (supports "inherit" semantics: null = inherit system default)
|
||||
export function formatRateLimitInheritable(rateLimit?: number | null): string {
|
||||
if (rateLimit == null) return '跟随系统'
|
||||
if (rateLimit === 0) return '不限速'
|
||||
if (rateLimit == null) return getI18nLocale() === 'en-US' ? 'Use system default' : '跟随系统'
|
||||
if (rateLimit === 0) return getI18nLocale() === 'en-US' ? 'No limit' : '不限速'
|
||||
return `${rateLimit}/min`
|
||||
}
|
||||
|
||||
// Rate limit formatting (simple: null/0 both mean unlimited)
|
||||
export function formatRateLimitSimple(rateLimit?: number | null): string {
|
||||
if (rateLimit == null || rateLimit === 0) return '不限速'
|
||||
if (rateLimit == null || rateLimit === 0) return getI18nLocale() === 'en-US' ? 'No limit' : '不限速'
|
||||
return `${rateLimit}/min`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { translateLegacyText } from '@/i18n/messages'
|
||||
|
||||
export type PasswordPolicyLevel = 'weak' | 'medium' | 'strong'
|
||||
export const PASSWORD_MAX_BYTES = 72
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
|
||||
function tr(value: string): string {
|
||||
return translateLegacyText(value, getI18nLocale())
|
||||
}
|
||||
|
||||
function getPasswordByteLength(password: string): number {
|
||||
return textEncoder.encode(password).length
|
||||
}
|
||||
@@ -39,24 +46,24 @@ export function normalizePasswordPolicyLevel(value: unknown): PasswordPolicyLeve
|
||||
export function getPasswordPolicyHint(level: unknown): string {
|
||||
switch (normalizePasswordPolicyLevel(level)) {
|
||||
case 'medium':
|
||||
return '至少 8 个字符,且需包含字母和数字'
|
||||
return tr('至少 8 个字符,且需包含字母和数字')
|
||||
case 'strong':
|
||||
return '至少 8 个字符,且需包含大写字母、小写字母、数字和特殊字符'
|
||||
return tr('至少 8 个字符,且需包含大写字母、小写字母、数字和特殊字符')
|
||||
case 'weak':
|
||||
default:
|
||||
return '至少 6 个字符'
|
||||
return tr('至少 6 个字符')
|
||||
}
|
||||
}
|
||||
|
||||
export function getPasswordPolicyPlaceholder(level: unknown): string {
|
||||
switch (normalizePasswordPolicyLevel(level)) {
|
||||
case 'medium':
|
||||
return '至少 8 位,含字母和数字'
|
||||
return tr('至少 8 位,含字母和数字')
|
||||
case 'strong':
|
||||
return '至少 8 位,含大小写字母、数字和特殊字符'
|
||||
return tr('至少 8 位,含大小写字母、数字和特殊字符')
|
||||
case 'weak':
|
||||
default:
|
||||
return '至少 6 个字符'
|
||||
return tr('至少 6 个字符')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +71,7 @@ export function getPasswordPolicyPlaceholder(level: unknown): string {
|
||||
* 返回所有未满足的密码策略条件。
|
||||
* 空数组 = 密码合规。
|
||||
*/
|
||||
export function getPasswordPolicyErrors(password: string, level: unknown): string[] {
|
||||
function getPasswordPolicyErrorSources(password: string, level: unknown): string[] {
|
||||
if (!password) return []
|
||||
|
||||
const normalized = normalizePasswordPolicyLevel(level)
|
||||
@@ -96,15 +103,23 @@ export function getPasswordPolicyErrors(password: string, level: unknown): strin
|
||||
return errors
|
||||
}
|
||||
|
||||
export function getPasswordPolicyErrors(password: string, level: unknown): string[] {
|
||||
return getPasswordPolicyErrorSources(password, level).map(tr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧接口:返回单条错误字符串,空字符串表示通过。
|
||||
* 多条未满足条件时用顿号连接。
|
||||
*/
|
||||
export function validatePasswordByPolicy(password: string, level: unknown): string {
|
||||
const errors = getPasswordPolicyErrors(password, level)
|
||||
const rawErrors = getPasswordPolicyErrorSources(password, level)
|
||||
const errors = rawErrors.map(tr)
|
||||
if (errors.length === 0) return ''
|
||||
if (errors.length === 1 && errors[0].startsWith('长度不能超过')) {
|
||||
return `密码${ errors[0]}`
|
||||
if (rawErrors.length === 1 && rawErrors[0].startsWith('长度不能超过')) {
|
||||
return tr(`密码${rawErrors[0]}`)
|
||||
}
|
||||
return `密码需要:${ errors.join('、')}`
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
return `Password requires: ${errors.join(', ')}`
|
||||
}
|
||||
return `密码需要:${errors.join('、')}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
|
||||
export function walletStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -7,6 +8,14 @@ export function walletStatusLabel(status: string | null | undefined): string {
|
||||
closed: '已关闭',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
suspended: 'Frozen',
|
||||
closed: 'Closed',
|
||||
}
|
||||
return englishLabels[status] || status
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
@@ -34,10 +43,20 @@ export function walletTransactionCategoryLabel(category: string | null | undefin
|
||||
refund: '退款',
|
||||
}
|
||||
if (!category) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
recharge: 'Top-up',
|
||||
gift: 'Grant',
|
||||
adjust: 'Adjustment',
|
||||
refund: 'Refund',
|
||||
}
|
||||
return englishLabels[category] || category
|
||||
}
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
export function dailyUsageCategoryLabel(isToday = false): string {
|
||||
if (getI18nLocale() === 'en-US') return isToday ? 'Today usage' : 'Daily usage'
|
||||
return isToday ? '今日消费' : '每日消费'
|
||||
}
|
||||
|
||||
@@ -61,6 +80,21 @@ export function walletTransactionReasonLabel(reasonCode: string | null | undefin
|
||||
refund_revert: '退款回补',
|
||||
}
|
||||
if (!reasonCode) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
topup_admin_manual: 'Manual top-up',
|
||||
topup_gateway: 'Payment top-up',
|
||||
topup_card_code: 'Card code top-up',
|
||||
gift_initial: 'Initial grant',
|
||||
gift_campaign: 'Campaign grant',
|
||||
gift_expire_reclaim: 'Grant reclaim',
|
||||
adjust_admin: 'Manual adjustment',
|
||||
adjust_system: 'System adjustment',
|
||||
refund_out: 'Refund deduction',
|
||||
refund_revert: 'Refund reversal',
|
||||
}
|
||||
return englishLabels[reasonCode] || reasonCode
|
||||
}
|
||||
return labels[reasonCode] || reasonCode
|
||||
}
|
||||
|
||||
@@ -82,6 +116,25 @@ export function paymentMethodLabel(method: string | null | undefined): string {
|
||||
offline: '线下转账',
|
||||
}
|
||||
if (!method) return '-'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
alipay: 'Alipay',
|
||||
wechat: 'WeChat Pay',
|
||||
wxpay: 'WeChat Pay',
|
||||
wechat_pay: 'WeChat Pay',
|
||||
epay: 'E-Pay',
|
||||
stripe: 'Stripe',
|
||||
card: 'Bank card / credit card',
|
||||
link: 'Stripe Link',
|
||||
admin_manual: 'Manual top-up',
|
||||
card_code: 'Top-up code',
|
||||
gift_code: 'Gift code',
|
||||
card_recharge: 'Card code top-up',
|
||||
bank_transfer: 'Bank transfer',
|
||||
offline: 'Offline transfer',
|
||||
}
|
||||
return englishLabels[method] || method
|
||||
}
|
||||
return labels[method] || method
|
||||
}
|
||||
|
||||
@@ -96,6 +149,18 @@ export function paymentStatusLabel(status: string | null | undefined): string {
|
||||
refunded: '已退款',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
credited: 'Credited',
|
||||
failed: 'Failed',
|
||||
expired: 'Expired',
|
||||
refunding: 'Refunding',
|
||||
refunded: 'Refunded',
|
||||
}
|
||||
return englishLabels[status] || status
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
@@ -109,6 +174,17 @@ export function walletLinkTypeLabel(type: string | null | undefined): string {
|
||||
usage: '用量记录',
|
||||
}
|
||||
if (!type) return '-'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
payment_order: 'Top-up order',
|
||||
refund_request: 'Refund request',
|
||||
admin_action: 'Admin action',
|
||||
system_task: 'System task',
|
||||
campaign: 'Campaign batch',
|
||||
usage: 'Usage record',
|
||||
}
|
||||
return englishLabels[type] || 'Other'
|
||||
}
|
||||
return labels[type] || '其他'
|
||||
}
|
||||
|
||||
@@ -127,6 +203,13 @@ export function refundModeLabel(mode: string | null | undefined): string {
|
||||
offline_payout: '线下打款',
|
||||
}
|
||||
if (!mode) return '-'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
original_channel: 'Original channel',
|
||||
offline_payout: 'Offline payout',
|
||||
}
|
||||
return englishLabels[mode] || mode
|
||||
}
|
||||
return labels[mode] || mode
|
||||
}
|
||||
|
||||
@@ -140,6 +223,17 @@ export function refundStatusLabel(status: string | null | undefined): string {
|
||||
cancelled: '已取消',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
pending_approval: 'Pending approval',
|
||||
approved: 'Approved',
|
||||
processing: 'Processing',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
}
|
||||
return englishLabels[status] || status
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
@@ -160,6 +254,16 @@ export function callbackStatusLabel(status: string | null | undefined): string {
|
||||
error: '处理失败',
|
||||
}
|
||||
if (!status) return '未知'
|
||||
if (getI18nLocale() === 'en-US') {
|
||||
const englishLabels: Record<string, string> = {
|
||||
processed: 'Processed',
|
||||
duplicate: 'Duplicate callback',
|
||||
ignored: 'Ignored',
|
||||
invalid_signature: 'Invalid signature',
|
||||
error: 'Failed',
|
||||
}
|
||||
return englishLabels[status] || status
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
|
||||
@@ -862,6 +862,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useI18n } from '@/i18n'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -904,6 +905,7 @@ import { log } from '@/utils/logger'
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.canAccessAdmin)
|
||||
const { toast } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
@@ -1108,7 +1110,7 @@ async function openUsageRecord(task: AsyncTaskItem) {
|
||||
|
||||
// 取消任务
|
||||
async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
||||
if (!confirm('确定要取消这个任务吗?')) return
|
||||
if (!confirm(legacyT('确定要取消这个任务吗?'))) return
|
||||
try {
|
||||
await asyncTasksApi.cancel(task.id)
|
||||
toast({
|
||||
|
||||
@@ -850,6 +850,7 @@ import {
|
||||
import { EmptyState, LoadingState, MultiSelect } from '@/components/common'
|
||||
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
@@ -887,6 +888,7 @@ interface PlanFormState {
|
||||
}
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -1366,7 +1368,7 @@ async function togglePlanStatus(plan: BillingPlan) {
|
||||
async function deletePlan(plan: BillingPlan) {
|
||||
if (deletingPlanId.value) return
|
||||
const confirmed = window.confirm(
|
||||
`确定删除套餐「${plan.title}」吗?\n\n已有订单或权益的套餐不能删除,请改为停用。删除后无法恢复。`
|
||||
legacyT(`确定删除套餐「${plan.title}」吗?\n\n已有订单或权益的套餐不能删除,请改为停用。删除后无法恢复。`)
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
|
||||
@@ -415,6 +415,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useI18n } from '@/i18n'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -441,6 +442,7 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { toast } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -543,7 +545,7 @@ async function fetchMappings() {
|
||||
}
|
||||
|
||||
async function deleteMapping(mapping: FileMappingResponse) {
|
||||
if (!confirm(`确定要删除映射 "${mapping.file_name}" 吗?\n\n注意:这只会删除映射记录,不会删除 Google 上的实际文件。`)) {
|
||||
if (!confirm(legacyT(`确定要删除映射 "${mapping.file_name}" 吗?\n\n注意:这只会删除映射记录,不会删除 Google 上的实际文件。`))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -564,7 +566,7 @@ async function deleteMapping(mapping: FileMappingResponse) {
|
||||
}
|
||||
|
||||
async function cleanupExpired() {
|
||||
if (!confirm('确定要清理所有过期的文件映射吗?')) {
|
||||
if (!confirm(legacyT('确定要清理所有过期的文件映射吗?'))) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +1,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Card
|
||||
v-if="providerDeleteProgress"
|
||||
class="border-primary/30 bg-primary/5"
|
||||
>
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-foreground">
|
||||
正在删除提供商:{{ providerDeleteProgress.providerName }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ providerDeleteStageLabel }} · {{ providerDeleteProgress.message || '后台处理中' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-xs font-medium text-primary">
|
||||
{{ providerDeleteOverallPercent }}%
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>总体进度</span>
|
||||
<span>{{ providerDeleteCompletedUnits }}/{{ providerDeleteTotalUnits }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
:style="{ width: `${providerDeleteOverallPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>账号删除</span>
|
||||
<span>{{ providerDeleteProgress.deletedKeys }}/{{ providerDeleteProgress.totalKeys || '...' }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary/80 transition-all duration-300"
|
||||
:style="{ width: `${providerDeleteKeysPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>端点删除</span>
|
||||
<span>{{ providerDeleteProgress.deletedEndpoints }}/{{ providerDeleteProgress.totalEndpoints || '...' }}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-primary/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary/60 transition-all duration-300"
|
||||
:style="{ width: `${providerDeleteEndpointsPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<ProviderDeleteProgressCard
|
||||
:progress="providerDeleteProgress"
|
||||
:stage-label="providerDeleteStageLabel"
|
||||
:total-units="providerDeleteTotalUnits"
|
||||
:completed-units="providerDeleteCompletedUnits"
|
||||
:overall-percent="providerDeleteOverallPercent"
|
||||
:keys-percent="providerDeleteKeysPercent"
|
||||
:endpoints-percent="providerDeleteEndpointsPercent"
|
||||
/>
|
||||
|
||||
<!-- 提供商表格 -->
|
||||
<Card
|
||||
@@ -105,24 +48,12 @@
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="providers.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
class="contents"
|
||||
>
|
||||
<div class="text-muted-foreground mb-2">
|
||||
<template v-if="hasActiveFilters">
|
||||
未找到匹配当前筛选条件的提供商
|
||||
</template>
|
||||
<template v-else>
|
||||
暂无提供商,点击右上角添加
|
||||
</template>
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="resetFilters"
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
<ProviderEmptyState
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@reset-filters="resetFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
@@ -134,10 +65,10 @@
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[18%] min-w-[140px]">
|
||||
提供商信息
|
||||
{{ legacyT('提供商信息') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-[20%] min-w-[180px]">
|
||||
余额监控
|
||||
{{ legacyT('余额监控') }}
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
class="w-[12%] min-w-[100px] text-center"
|
||||
@@ -145,10 +76,10 @@
|
||||
:sortable="false"
|
||||
align="center"
|
||||
:filter-active="filterModel !== 'all'"
|
||||
filter-title="筛选模型"
|
||||
:filter-title="legacyT('筛选模型')"
|
||||
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
资源统计
|
||||
{{ legacyT('资源统计') }}
|
||||
<template #filter="{ close }">
|
||||
<TableFilterMenu
|
||||
v-model="filterModel"
|
||||
@@ -162,10 +93,10 @@
|
||||
column-key="api_format"
|
||||
:sortable="false"
|
||||
:filter-active="filterApiFormat !== 'all'"
|
||||
filter-title="筛选 API 格式"
|
||||
:filter-title="legacyT('筛选 API 格式')"
|
||||
filter-content-class="w-72 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
端点健康
|
||||
{{ legacyT('端点健康') }}
|
||||
<template #filter="{ close }">
|
||||
<TableFilterMenu
|
||||
v-model="filterApiFormat"
|
||||
@@ -180,10 +111,10 @@
|
||||
:sortable="false"
|
||||
align="center"
|
||||
:filter-active="filterStatus !== 'all'"
|
||||
filter-title="筛选状态"
|
||||
:filter-title="legacyT('筛选状态')"
|
||||
filter-content-class="w-40 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
状态
|
||||
{{ legacyT('状态') }}
|
||||
<template #filter="{ close }">
|
||||
<TableFilterMenu
|
||||
v-model="filterStatus"
|
||||
@@ -193,7 +124,7 @@
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<TableHead class="w-[18%] min-w-[160px] text-center">
|
||||
操作
|
||||
{{ legacyT('操作') }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -309,7 +240,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
@@ -325,6 +255,8 @@ import ProviderDetailDrawer from '@/features/providers/components/ProviderDetail
|
||||
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
|
||||
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
|
||||
import ProviderMobileCard from '@/features/providers/components/ProviderMobileCard.vue'
|
||||
import ProviderDeleteProgressCard from '@/features/providers/components/ProviderDeleteProgressCard.vue'
|
||||
import ProviderEmptyState from '@/features/providers/components/ProviderEmptyState.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
@@ -341,6 +273,7 @@ import {
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface ProviderDeleteProgressState {
|
||||
providerId: string
|
||||
@@ -357,6 +290,11 @@ interface ProviderDeleteProgressState {
|
||||
|
||||
const { error: showError, success: showSuccess, info: showInfo } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function showLegacyError(err: unknown, fallback: string, title = '错误') {
|
||||
showError(legacyT(parseApiError(err, fallback)), legacyT(title))
|
||||
}
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -429,23 +367,23 @@ async function pollProviderDeleteTask(providerId: string, taskId: string) {
|
||||
const providerDeleteStageLabel = computed(() => {
|
||||
switch (providerDeleteProgress.value?.stage) {
|
||||
case 'preparing':
|
||||
return '准备删除'
|
||||
return legacyT('准备删除')
|
||||
case 'disabling':
|
||||
return '停用提供商'
|
||||
return legacyT('停用提供商')
|
||||
case 'cleaning_restrictions':
|
||||
return '清理访问限制'
|
||||
return legacyT('清理访问限制')
|
||||
case 'cleaning_provider_refs':
|
||||
return '清理历史引用'
|
||||
return legacyT('清理历史引用')
|
||||
case 'deleting_keys':
|
||||
return '删除号池账号'
|
||||
return legacyT('删除号池账号')
|
||||
case 'deleting_endpoints':
|
||||
return '删除端点'
|
||||
return legacyT('删除端点')
|
||||
case 'completed':
|
||||
return '删除完成'
|
||||
return legacyT('删除完成')
|
||||
case 'failed':
|
||||
return '删除失败'
|
||||
return legacyT('删除失败')
|
||||
default:
|
||||
return '等待执行'
|
||||
return legacyT('等待执行')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -570,14 +508,14 @@ async function saveDescription(_event: Event, provider: ProviderWithEndpointsSum
|
||||
}
|
||||
cancelEditDescription()
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '更新备注失败'), '错误')
|
||||
showLegacyError(err, '更新备注失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级模式配置
|
||||
const priorityModeConfig = computed(() => {
|
||||
return {
|
||||
label: priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先',
|
||||
label: legacyT(priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -632,7 +570,7 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
||||
loadBalances(providers.value)
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载提供商列表失败'), '错误')
|
||||
showLegacyError(err, '加载提供商列表失败')
|
||||
} finally {
|
||||
if (requestId === providersRequestId) {
|
||||
loading.value = false
|
||||
@@ -711,7 +649,7 @@ async function refreshProviderSnapshot(
|
||||
mergeUpdatedProvider(updated)
|
||||
return updated
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, fallbackErrorMessage), '错误')
|
||||
showLegacyError(err, fallbackErrorMessage)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -761,8 +699,8 @@ function handleProviderAdded() {
|
||||
// 删除提供商
|
||||
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
const confirmed = await confirmDanger(
|
||||
'删除提供商',
|
||||
`确定要删除提供商 "${provider.name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`,
|
||||
legacyT('删除提供商'),
|
||||
legacyT(`确定要删除提供商 "${provider.name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`),
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -781,7 +719,7 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
deletedEndpoints: 0,
|
||||
message: result.message || '删除任务已提交,后台处理中',
|
||||
}
|
||||
showInfo(result.message || '删除任务已提交,后台处理中')
|
||||
showInfo(legacyT(result.message || '删除任务已提交,后台处理中'))
|
||||
|
||||
const task = await pollProviderDeleteTask(provider.id, result.task_id)
|
||||
if (!task) return // aborted
|
||||
@@ -789,12 +727,12 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
throw new Error(task.message || 'provider delete task failed')
|
||||
}
|
||||
|
||||
showSuccess('提供商已删除')
|
||||
showSuccess(legacyT('提供商已删除'))
|
||||
providerDeleteProgress.value = null
|
||||
void loadProviders()
|
||||
} catch (err: unknown) {
|
||||
providerDeleteProgress.value = null
|
||||
showError(parseApiError(err, '删除提供商失败'), '错误')
|
||||
showLegacyError(err, '删除提供商失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,9 +751,9 @@ async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||
targetProvider.is_active = newStatus
|
||||
}
|
||||
|
||||
showSuccess(newStatus ? '提供商已启用' : '提供商已停用')
|
||||
showSuccess(legacyT(newStatus ? '提供商已启用' : '提供商已停用'))
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '操作失败'), '错误')
|
||||
showLegacyError(err, '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+153
-1214
File diff suppressed because it is too large
Load Diff
+249
-1572
File diff suppressed because it is too large
Load Diff
@@ -1383,6 +1383,7 @@ import {
|
||||
import type { PaymentOrder } from '@/api/wallet'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
callbackStatusBadge,
|
||||
@@ -1422,6 +1423,7 @@ const LEDGER_REASON_OPTIONS: LedgerReasonOption[] = [
|
||||
]
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { legacyT } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const activeTab = ref<WalletManagementTab>('ledger')
|
||||
@@ -1870,7 +1872,7 @@ async function deleteRedeemBatch(batch: RedeemCodeBatch) {
|
||||
showError('已有兑换记录的批次不能删除')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`确认删除批次「${batch.name}」吗?删除后无法恢复。`)) {
|
||||
if (!window.confirm(legacyT(`确认删除批次「${batch.name}」吗?删除后无法恢复。`))) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { Cpu } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{ node: ProxyNode }>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const hardwareInfo = computed<Record<string, unknown> | null>(() => {
|
||||
return normalizeHardwareInfo(props.node.hardware_info)
|
||||
@@ -89,7 +91,7 @@ const hardwareRows = computed(() => {
|
||||
const nativeTooltipText = computed(() =>
|
||||
hardwareRows.value.length > 0
|
||||
? hardwareRows.value.map((row) => `${row.label}: ${row.value}`).join('\n')
|
||||
: '暂无硬件信息上报'
|
||||
: legacyT('暂无硬件信息上报')
|
||||
)
|
||||
|
||||
const showHardwareInfo = computed(
|
||||
@@ -171,7 +173,7 @@ function pickString(...values: unknown[]): string {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="硬件信息"
|
||||
:aria-label="legacyT('硬件信息')"
|
||||
:title="nativeTooltipText"
|
||||
class="inline-flex items-center justify-center rounded-sm p-0.5 hover:bg-muted/60 transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -187,7 +189,7 @@ function pickString(...values: unknown[]): string {
|
||||
v-if="hardwareRows.length === 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
暂无硬件信息上报
|
||||
{{ legacyT('暂无硬件信息上报') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="legacyT('批量升级')"
|
||||
:description="legacyT('给所有 tunnel 节点写入升级目标,节点会在下次心跳自动领取')"
|
||||
:icon="Settings"
|
||||
size="sm"
|
||||
@update:model-value="$emit('update:open', $event)"
|
||||
>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="$emit('submit')"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ legacyT('目标版本') }}</Label>
|
||||
<Input
|
||||
:model-value="version"
|
||||
:placeholder="legacyT('例如 0.2.3')"
|
||||
@update:model-value="$emit('update:version', String($event))"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('gateway 只会写入 `upgrade_to` 目标版本,不再维护分波 rollout 或确认状态。') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
{{ legacyT('取消') }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="upgrading || !version.trim()"
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
{{ upgrading ? legacyT('下发中...') : legacyT('确认下发') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Settings } from 'lucide-vue-next'
|
||||
import { Button, Dialog, Input, Label } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
version: string
|
||||
upgrading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'update:version': [value: string]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
@@ -4,13 +4,13 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-sm font-semibold">
|
||||
节点数据
|
||||
{{ legacyT('节点数据') }}
|
||||
</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
最近 24 小时
|
||||
{{ legacyT('最近 24 小时') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
@@ -28,7 +28,7 @@
|
||||
class="h-3.5 w-3.5 mr-1"
|
||||
:class="state?.loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
刷新
|
||||
{{ legacyT('刷新') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
class="py-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
加载节点数据...
|
||||
{{ legacyT('加载节点数据...') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -59,7 +59,7 @@
|
||||
class="rounded-lg border border-border/50 bg-background/70 px-3 py-2 min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
{{ legacyT(item.label) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-semibold tabular-nums truncate mt-0.5"
|
||||
@@ -71,7 +71,7 @@
|
||||
v-if="item.hint"
|
||||
class="text-[10px] text-muted-foreground truncate mt-0.5"
|
||||
>
|
||||
{{ item.hint }}
|
||||
{{ legacyT(item.hint) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,13 +81,13 @@
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h5 class="text-xs font-semibold">
|
||||
在线率采样
|
||||
{{ legacyT('在线率采样') }}
|
||||
</h5>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
1h 桶,颜色随在线率和错误数变化
|
||||
{{ legacyT('1h 桶,颜色随在线率和错误数变化') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ formatNumber(bucketItems.length) }} 点</span>
|
||||
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ legacyT(`${formatNumber(bucketItems.length)} 点`) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="bucketItems.length > 0"
|
||||
@@ -105,14 +105,14 @@
|
||||
v-else
|
||||
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无采样数据
|
||||
{{ legacyT('暂无采样数据') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<h5 class="text-xs font-semibold">
|
||||
最近事件
|
||||
{{ legacyT('最近事件') }}
|
||||
</h5>
|
||||
<span class="text-[11px] text-muted-foreground tabular-nums">{{ recentEvents.length }}/8</span>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@
|
||||
v-if="recentEvents.length === 0"
|
||||
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无关键事件
|
||||
{{ legacyT('暂无关键事件') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -135,12 +135,12 @@
|
||||
:variant="eventTypeVariant(event.event_type)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ eventTypeLabel(event.event_type) }}
|
||||
{{ legacyT(eventTypeLabel(event.event_type)) }}
|
||||
</Badge>
|
||||
<span
|
||||
class="text-muted-foreground truncate flex-1"
|
||||
:title="eventTooltip(event)"
|
||||
>{{ eventDetail(event) }}</span>
|
||||
:title="legacyT(eventTooltip(event))"
|
||||
>{{ legacyT(eventDetail(event)) }}</span>
|
||||
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
硬件与资源
|
||||
{{ legacyT('硬件与资源') }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div
|
||||
@@ -159,19 +159,19 @@
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
{{ legacyT(item.label) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-medium tabular-nums truncate mt-0.5"
|
||||
:class="item.tone"
|
||||
>
|
||||
{{ item.value }}
|
||||
{{ legacyT(item.value) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.hint"
|
||||
class="text-[10px] text-muted-foreground truncate mt-0.5"
|
||||
>
|
||||
{{ item.hint }}
|
||||
{{ legacyT(item.hint) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
实时快照
|
||||
{{ legacyT('实时快照') }}
|
||||
</h5>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div
|
||||
@@ -188,10 +188,10 @@
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
{{ legacyT(item.label) }}
|
||||
</div>
|
||||
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
|
||||
{{ item.value }}
|
||||
{{ legacyT(item.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
隧道计数器
|
||||
{{ legacyT('隧道计数器') }}
|
||||
</h5>
|
||||
<div
|
||||
v-if="tunnelMetrics"
|
||||
@@ -211,10 +211,10 @@
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
{{ legacyT(item.label) }}
|
||||
</div>
|
||||
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
|
||||
{{ item.value }}
|
||||
{{ legacyT(item.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,7 +222,7 @@
|
||||
v-else
|
||||
class="rounded-md bg-muted/30 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无隧道计数器
|
||||
{{ legacyT('暂无隧道计数器') }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -234,6 +234,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Badge, Button } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import type {
|
||||
ProxyNode,
|
||||
@@ -260,6 +261,8 @@ defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const detailNode = computed(() => props.state?.node ?? props.node)
|
||||
const metrics = computed(() => props.state?.metrics ?? null)
|
||||
const metricsSummary = computed(() => metrics.value?.summary ?? null)
|
||||
@@ -271,9 +274,11 @@ const resourceUsage = computed(() => asRecord(metadata.value?.resource_usage))
|
||||
const tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
|
||||
|
||||
const loadedText = computed(() => {
|
||||
if (props.state?.loading) return '正在刷新采样数据'
|
||||
if (!props.state?.loadedAt) return '展开后自动读取 24 小时指标和最近关键事件'
|
||||
return `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
|
||||
if (props.state?.loading) return legacyT('正在刷新采样数据')
|
||||
if (!props.state?.loadedAt) return legacyT('展开后自动读取 24 小时指标和最近关键事件')
|
||||
return locale.value === 'en-US'
|
||||
? `Data refreshed at ${formatLoadedAt(props.state.loadedAt)}`
|
||||
: `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
|
||||
})
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
@@ -299,7 +304,7 @@ const summaryStats = computed(() => {
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: '断开',
|
||||
label: '断开次数',
|
||||
value: formatNumber(summary?.disconnects_delta ?? 0),
|
||||
hint: '24h delta',
|
||||
tone: (summary?.disconnects_delta ?? 0) > 0 ? 'text-yellow-600 dark:text-yellow-400' : '',
|
||||
@@ -560,14 +565,20 @@ function formatTime(iso: string | null) {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
const diff = (Date.now() - date.getTime()) / 1000
|
||||
if (locale.value === 'en-US') {
|
||||
if (diff >= 0 && diff < 60) return 'Just now'
|
||||
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return date.toLocaleDateString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
if (diff >= 0 && diff < 60) return '刚刚'
|
||||
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
||||
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return date.toLocaleDateString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatLoadedAt(value: number) {
|
||||
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
return new Date(value).toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function bucketBarHeight(bucket: ProxyNodeMetricsBucket) {
|
||||
@@ -591,20 +602,21 @@ function bucketBarColor(bucket: ProxyNodeMetricsBucket) {
|
||||
}
|
||||
|
||||
function bucketTitle(bucket: ProxyNodeMetricsBucket) {
|
||||
return [
|
||||
const parts = [
|
||||
formatBucketTime(bucket.bucket_start),
|
||||
`在线率 ${formatPercent(bucket.uptime_ratio)}`,
|
||||
`${legacyT('在线率')} ${formatPercent(bucket.uptime_ratio)}`,
|
||||
`RTT ${formatMs(bucket.heartbeat_rtt_ms_avg)}`,
|
||||
`断开 ${formatNumber(bucket.disconnects_delta)}`,
|
||||
`错误 ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
|
||||
].join(',')
|
||||
`${legacyT('断开')} ${formatNumber(bucket.disconnects_delta)}`,
|
||||
`${legacyT('错误')} ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
|
||||
]
|
||||
return parts.join(locale.value === 'en-US' ? ', ' : ',')
|
||||
}
|
||||
|
||||
function formatBucketTime(value: string | null) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit' })
|
||||
return date.toLocaleString(locale.value, { month: '2-digit', day: '2-digit', hour: '2-digit' })
|
||||
}
|
||||
|
||||
function uptimeTone(value: number | null) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
:title="legacyT('连接事件')"
|
||||
:description="description"
|
||||
size="lg"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="node"
|
||||
class="grid grid-cols-3 gap-3 text-sm"
|
||||
>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">{{ legacyT('失败请求') }}</span>
|
||||
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.failed_requests || 0) }}</span>
|
||||
</div>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">{{ legacyT('DNS 失败') }}</span>
|
||||
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.dns_failures || 0) }}</span>
|
||||
</div>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">{{ legacyT('流错误') }}</span>
|
||||
<span class="tabular-nums font-medium">{{ formatProxyNodeNumber(node.stream_errors || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-8 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
{{ legacyT('加载中...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="events.length === 0"
|
||||
class="py-8 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
{{ legacyT('暂无连接事件记录') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-80 overflow-y-auto space-y-1.5"
|
||||
>
|
||||
<div
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 text-sm"
|
||||
>
|
||||
<Badge
|
||||
:variant="proxyNodeEventTypeVariant(event.event_type)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ legacyT(proxyNodeEventTypeLabel(event.event_type)) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground truncate flex-1">{{ proxyNodeEventDetail(event) }}</span>
|
||||
<span class="text-xs text-muted-foreground/70 tabular-nums shrink-0">{{ formatProxyNodeTime(event.created_at, locale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
{{ legacyT('关闭') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Badge, Button, Dialog } from '@/components/ui'
|
||||
import type { ProxyNode, ProxyNodeEvent } from '@/api/proxy-nodes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
formatProxyNodeNumber,
|
||||
formatProxyNodeTime,
|
||||
proxyNodeEventDetail,
|
||||
proxyNodeEventTypeLabel,
|
||||
proxyNodeEventTypeVariant,
|
||||
} from './proxy-node-display'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
node: ProxyNode | null
|
||||
events: ProxyNodeEvent[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const description = computed(() => {
|
||||
if (!props.node) return ''
|
||||
return locale.value === 'en-US'
|
||||
? `${props.node.name} connection history`
|
||||
: `${props.node.name} 的连接历史`
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user