mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
refactor(frontend): modularize i18n architecture
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user