refactor: migrate events management ui to shadcn

This commit is contained in:
安正超
2025-10-25 22:26:47 +08:00
parent be229fc694
commit c019d208c8
25 changed files with 1979 additions and 1186 deletions
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { Icon } from '#components';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import LanguageSwitcher from '@/components/language-switcher.vue';
import ThemeSwitcher from '@/components/theme-switcher.vue';
import UserDropdown from '@/components/user-dropdown.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, RouterLink } from 'vue-router';
import type { AppConfig, NavItem } from '~/types/app-config';
import { icon } from '~/utils/ui';
const appConfig = useAppConfig() as unknown as AppConfig;
const route = useRoute();
const { t } = useI18n();
const { state } = useSidebar();
const isCollapsed = computed(() => state.value === 'collapsed');
const navGroups = computed(() => {
const groups: NavItem[][] = [];
let current: NavItem[] = [];
for (const nav of appConfig.navs) {
if (nav.type === 'divider') {
if (current.length) {
groups.push(current);
current = [];
}
continue;
}
current.push(nav);
}
if (current.length) {
groups.push(current);
}
return groups;
});
const normalizeTo = (item: NavItem) => item.to || '/';
const isExternalLink = (item: NavItem) => Boolean(item.target) || /^https?:/i.test(item.to || '');
const isRouteActive = (item: NavItem): boolean => {
if (!item.to) {
if (item.children?.length) {
return item.children.some(child => isRouteActive(child));
}
return false;
}
if (item.children?.length) {
return item.children.some(child => isRouteActive(child));
}
if (isExternalLink(item)) {
return false;
}
return route.path.startsWith(item.to);
};
const renderLabel = (item: NavItem) => t(item.label);
const renderIcon = (item: NavItem) => {
if (!item.icon) {
return null;
}
return icon(item.icon);
};
</script>
<template>
<Sidebar collapsible="icon" class="bg-sidebar text-sidebar-foreground">
<SidebarHeader class="border-b border-sidebar-border px-4 py-3">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold">
<span v-if="isCollapsed">{{ appConfig.name.substring(0, 1) }}</span>
<img v-else src="~/assets/logo.svg" alt="RustFS" class="h-8" />
</div>
<div v-if="!isCollapsed" class="flex flex-col">
<span class="text-sm font-semibold leading-tight">{{ appConfig.name }}</span>
<span class="text-xs text-muted-foreground">{{ appConfig.description }}</span>
</div>
</div>
<SidebarTrigger class="ml-auto" />
</SidebarHeader>
<SidebarContent class="px-2 py-4">
<div class="flex flex-col gap-6">
<template v-for="(group, groupIndex) in navGroups" :key="groupIndex">
<SidebarMenu>
<SidebarMenuItem v-for="item in group" :key="item.label">
<component
v-if="item.children?.length"
:is="SidebarMenuButton"
:is-active="isRouteActive(item)"
class="items-start"
>
<component
:is="isExternalLink(item) ? 'a' : RouterLink"
:to="isExternalLink(item) ? undefined : normalizeTo(item)"
:href="isExternalLink(item) ? normalizeTo(item) : undefined"
:target="item.target"
class="flex flex-1 items-center gap-3"
>
<component :is="renderIcon(item)" v-if="item.icon" />
<span class="flex-1 text-sm font-medium">{{ renderLabel(item) }}</span>
</component>
</component>
<SidebarMenuSub v-if="item.children?.length">
<SidebarMenuSubItem v-for="child in item.children" :key="child.label">
<SidebarMenuSubButton
as-child
size="sm"
:is-active="isRouteActive(child)"
>
<component
:is="isExternalLink(child) ? 'a' : RouterLink"
:to="isExternalLink(child) ? undefined : normalizeTo(child)"
:href="isExternalLink(child) ? normalizeTo(child) : undefined"
:target="child.target"
class="flex flex-1 items-center gap-2"
>
<component :is="renderIcon(child)" v-if="child.icon" />
<span>{{ renderLabel(child) }}</span>
</component>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
<SidebarMenuButton v-else as-child :is-active="isRouteActive(item)">
<component
:is="isExternalLink(item) ? 'a' : RouterLink"
:to="isExternalLink(item) ? undefined : normalizeTo(item)"
:href="isExternalLink(item) ? normalizeTo(item) : undefined"
:target="item.target"
class="flex w-full items-center gap-3"
>
<component :is="renderIcon(item)" v-if="item.icon" />
<span class="flex-1 truncate text-sm font-medium">{{ renderLabel(item) }}</span>
<Icon
v-if="isExternalLink(item)"
name="ri:external-link-line"
class="h-3.5 w-3.5 text-muted-foreground"
/>
</component>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div v-if="groupIndex !== navGroups.length - 1" class="border-t border-sidebar-border" />
</template>
</div>
</SidebarContent>
<SidebarFooter class="mt-auto border-t border-sidebar-border px-4 py-4">
<div class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-2" :class="{ 'grid-cols-1': isCollapsed, 'grid-cols-2': !isCollapsed }">
<LanguageSwitcher />
<ThemeSwitcher v-if="!isCollapsed" />
</div>
<UserDropdown :is-collapsed="isCollapsed" />
</div>
</SidebarFooter>
</Sidebar>
<SidebarRail />
</template>
+260 -193
View File
@@ -1,128 +1,230 @@
<template>
<n-modal
v-model:show="visible"
:mask-closable="false"
preset="card"
:title="t('Subscribe to event notification') + ` (${t('Bucket')}: ${bucketName})`"
class="max-w-screen-md"
:segmented="{
content: true,
action: true,
}"
>
<n-card>
<n-form ref="formRef" :model="formData" :rules="rules" label-placement="left" label-width="140px">
<n-form-item :label="t('Amazon Resource Name')" path="resourceName">
<n-select
v-model:value="formData.resourceName"
filterable
:options="arnList"
:placeholder="t('Please select resource name')"
<Dialog :open="visible" @update:open="handleOpenChange">
<DialogContent
class="max-w-2xl"
@pointerDownOutside.prevent
@interactOutside.prevent
@escapeKeyDown.prevent
>
<DialogHeader class="text-left">
<DialogTitle>
{{ t('Subscribe to event notification') }}
<span class="block text-sm font-normal text-muted-foreground">
{{ `${t('Bucket')}: ${bucketName}` }}
</span>
</DialogTitle>
</DialogHeader>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-resource-name">{{ t('Amazon Resource Name') }}</Label>
<Select
id="event-resource-name"
v-model="formData.resourceName"
:disabled="!arnList.length"
>
<SelectTrigger>
<SelectValue :placeholder="t('Please select resource name')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in arnList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="errors.resourceName" class="sm:col-start-2 text-sm text-destructive">
{{ errors.resourceName }}
</p>
</div>
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-prefix">{{ t('Prefix') }}</Label>
<Input
id="event-prefix"
v-model="formData.prefix"
:placeholder="t('Please enter prefix')"
/>
</n-form-item>
</div>
<n-form-item :label="t('Prefix')">
<n-input v-model:value="formData.prefix" :placeholder="t('Please enter prefix')" />
</n-form-item>
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-suffix">{{ t('Suffix') }}</Label>
<Input
id="event-suffix"
v-model="formData.suffix"
:placeholder="t('Please enter suffix')"
/>
</div>
<n-form-item :label="t('Suffix')">
<n-input v-model:value="formData.suffix" :placeholder="t('Please enter suffix')" />
</n-form-item>
<div class="grid gap-4 sm:grid-cols-[160px_1fr]">
<Label>{{ t('Select events') }}</Label>
<div class="space-y-2">
<ScrollArea class="max-h-64 rounded-md border">
<div class="flex flex-col gap-2 p-4">
<label
v-for="event in eventOptions"
:key="event.value"
class="flex items-start gap-3"
>
<Checkbox
:checked="formData.events.includes(event.value)"
class="mt-1"
@update:checked="value => toggleEvent(event.value, value)"
/>
<span>{{ t(event.labelKey) }}</span>
</label>
</div>
</ScrollArea>
<p v-if="errors.events" class="text-sm text-destructive">
{{ errors.events }}
</p>
</div>
</div>
</div>
<n-form-item :label="t('Select events')" path="events">
<n-scrollbar class="w-full max-h-64">
<n-checkbox-group v-model:value="formData.events" class="flex flex-col">
<n-checkbox class="mt-2" value="PUT" :label="t('PUT - Object upload')" />
<n-checkbox class="mt-2" value="GET" :label="t('GET - Object access')" />
<n-checkbox class="mt-2" value="DELETE" :label="t('DELETE - Object deletion')" />
<n-checkbox class="mt-2" value="REPLICA" :label="t('REPLICA - Object migration')" />
<n-checkbox class="mt-2" value="RESTORE" :label="t('ILM - Object converted')" />
<n-checkbox
class="mt-2"
value="SCANNER"
:label="t('SCANNER - Object has too many versions/prefix has too many subfolders')"
/>
</n-checkbox-group>
</n-scrollbar>
</n-form-item>
<n-space justify="center">
<n-button @click="handleCancel">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="handleSubmit">{{ t('Save') }}</n-button>
</n-space>
</n-form>
</n-card>
</n-modal>
<div class="flex flex-col gap-2 pt-6 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" @click="handleCancel">
{{ t('Cancel') }}
</Button>
<Button type="button" @click="handleSubmit">
{{ t('Save') }}
</Button>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import type { FormItemRule, FormInst } from 'naive-ui';
import { NButton, NForm, NFormItem, NInput } from 'naive-ui';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { getEventTargetArnList } = useEventTarget();
const { putBucketNotifications, listBucketNotifications } = useBucket({});
const { t } = useI18n();
const formRef = ref<FormInst | null>(null);
const $message = useMessage();
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { getEventTargetArnList } = useEventTarget()
const { putBucketNotifications, listBucketNotifications } = useBucket({})
const { t } = useI18n()
const message = useMessage()
interface FormData {
resourceName: string;
prefix: string;
suffix: string;
events: string[];
resourceName: string
prefix: string
suffix: string
events: string[]
}
const formData = ref<FormData>({
resourceName: '',
prefix: '',
suffix: '',
events: [],
});
// 表单验证规则
const rules = {
resourceName: [{ required: true, message: t('Please select resource name'), trigger: ['change', 'blur'] }],
events: [
{
required: true,
message: t('Please select at least one event'),
trigger: ['change', 'blur'],
validator: (rule: any, value: string[]) => {
console.log('🚀 ~ value:', value);
if (!value || value.length === 0) {
return new Error(t('Please select at least one event'));
}
return true;
},
},
],
};
interface ArnOption {
label: string
value: string
}
const props = defineProps({
bucketName: {
type: String,
required: true,
},
});
})
const emit = defineEmits<{
success: [];
}>();
success: []
}>()
const visible = ref(false)
const formData = ref<FormData>({
resourceName: '',
prefix: '',
suffix: '',
events: [],
})
const errors = reactive({
resourceName: '',
events: '',
})
const eventOptions = [
{ value: 'PUT', labelKey: 'PUT - Object upload' },
{ value: 'GET', labelKey: 'GET - Object access' },
{ value: 'DELETE', labelKey: 'DELETE - Object deletion' },
{ value: 'REPLICA', labelKey: 'REPLICA - Object migration' },
{ value: 'RESTORE', labelKey: 'ILM - Object converted' },
{
value: 'SCANNER',
labelKey: 'SCANNER - Object has too many versions/prefix has too many subfolders',
},
]
const arnList = ref<ArnOption[]>([])
getEventTargetArnList().then((res: string[]) => {
arnList.value = res.map(item => ({
label: item,
value: item,
}))
})
const visible = ref(false);
const open = () => {
visible.value = true;
};
visible.value = true
}
const handleOpenChange = (value: boolean) => {
visible.value = value
if (!value) {
resetForm()
}
}
const resetForm = () => {
formData.value = {
resourceName: '',
prefix: '',
suffix: '',
events: [],
}
errors.resourceName = ''
errors.events = ''
}
const toggleEvent = (event: string, value: boolean | 'indeterminate') => {
const checked = value === true || value === 'indeterminate'
if (checked && !formData.value.events.includes(event)) {
formData.value.events.push(event)
} else if (!checked) {
formData.value.events = formData.value.events.filter(item => item !== event)
}
}
const validate = () => {
errors.resourceName = formData.value.resourceName ? '' : t('Please select resource name')
errors.events = formData.value.events.length ? '' : t('Please select at least one event')
return !errors.resourceName && !errors.events
}
defineExpose({
open,
});
const handleSubmit = async () => {
try {
// 进行表单验证
await formRef.value?.validate();
if (!validate()) {
return
}
// 事件类型映射:将简化的值映射到 S3 标准事件集合
try {
const eventMapping: Record<string, string[]> = {
PUT: ['s3:0bjectCreated:*'],
GET: ['s3:0bjectAccessed:*'],
@@ -130,185 +232,150 @@ const handleSubmit = async () => {
REPLICA: ['s3:Replication:*'],
RESTORE: ['s3:ObjectRestore:*', 's3:0bjectTransition:*'],
SCANNER: ['s3:Scanner:ManyVersions', 's3:Scanner:BigPrefix'],
};
}
// 将选中的简化事件转换为 S3 标准事件集合
const s3Events: string[] = [];
const s3Events: string[] = []
formData.value.events.forEach(event => {
if (eventMapping[event]) {
s3Events.push(...eventMapping[event]);
s3Events.push(...eventMapping[event])
} else {
// 如果不是预定义的事件,直接使用原值
s3Events.push(event);
s3Events.push(event)
}
});
})
// 去重,避免重复的事件
const uniqueS3Events = [...new Set(s3Events)];
const uniqueS3Events = [...new Set(s3Events)]
// 验证转换后的事件列表
if (uniqueS3Events.length === 0) {
$message.error(t('No valid events found after conversion'));
return;
if (!uniqueS3Events.length) {
message.error(t('No valid events found after conversion'))
return
}
// 先获取当前的通知配置
let currentNotifications: any = {};
let currentNotifications: any = {}
try {
const currentResponse = await listBucketNotifications(props.bucketName);
// AWS SDK 返回的数据结构可能不同,需要检查实际返回的字段
currentNotifications = currentResponse || {};
} catch (error: any) {
// 如果获取失败,使用空配置
console.warn('获取当前通知配置失败,将使用空配置:', error);
currentNotifications = {};
const currentResponse = await listBucketNotifications(props.bucketName)
currentNotifications = currentResponse || {}
} catch (error) {
console.warn('获取当前通知配置失败,将使用空配置:', error)
currentNotifications = {}
}
// 根据 ARN 类型构建通知配置
const arn = formData.value.resourceName;
const arn = formData.value.resourceName
const newNotificationConfig: {
LambdaFunctionConfigurations?: any[];
QueueConfigurations?: any[];
TopicConfigurations?: any[];
} = {};
LambdaFunctionConfigurations?: any[]
QueueConfigurations?: any[]
TopicConfigurations?: any[]
} = {}
// 创建基础配置对象
const baseConfig: {
Id: string;
Events: string[];
Id: string
Events: string[]
Filter?: {
Key: {
FilterRules: Array<{ Name: string; Value: string }>;
};
};
FilterRules: Array<{ Name: string; Value: string }>
}
}
} = {
Id: `notification-${Date.now()}`, // 生成唯一ID
Events: uniqueS3Events, // 使用转换后去重的 S3 标准事件
Id: `notification-${Date.now()}`,
Events: uniqueS3Events,
Filter: {
Key: {
FilterRules: [],
},
},
};
}
// 添加前缀和后缀过滤规则
if (formData.value.prefix) {
baseConfig.Filter!.Key.FilterRules.push({
Name: 'Prefix',
Value: formData.value.prefix,
});
})
}
if (formData.value.suffix) {
baseConfig.Filter!.Key.FilterRules.push({
Name: 'Suffix',
Value: formData.value.suffix,
});
})
}
// 如果没有过滤规则,移除 Filter 对象
if (baseConfig.Filter!.Key.FilterRules.length === 0) {
delete baseConfig.Filter;
if (!baseConfig.Filter!.Key.FilterRules.length) {
delete baseConfig.Filter
}
// 根据 ARN 类型确定配置类型
if (arn.includes(':lambda:')) {
// Lambda 函数配置
newNotificationConfig.LambdaFunctionConfigurations = [
{
...baseConfig,
LambdaFunctionArn: arn,
},
];
]
} else if (arn.includes(':sqs:')) {
// SQS 队列配置
newNotificationConfig.QueueConfigurations = [
{
...baseConfig,
QueueArn: arn,
},
];
]
} else if (arn.includes(':sns:')) {
// SNS 主题配置
newNotificationConfig.TopicConfigurations = [
{
...baseConfig,
TopicArn: arn,
},
];
]
} else {
// 默认使用 TopicConfigurations(适用于自定义事件目标)
newNotificationConfig.TopicConfigurations = [
{
...baseConfig,
TopicArn: arn,
},
];
]
}
// 合并当前配置和新配置
const mergedNotificationConfig = {
...currentNotifications,
...newNotificationConfig,
};
}
// 合并数组类型的配置
if (newNotificationConfig.LambdaFunctionConfigurations) {
mergedNotificationConfig.LambdaFunctionConfigurations = [
...(currentNotifications.LambdaFunctionConfigurations || []),
...newNotificationConfig.LambdaFunctionConfigurations,
];
]
}
if (newNotificationConfig.QueueConfigurations) {
mergedNotificationConfig.QueueConfigurations = [
...(currentNotifications.QueueConfigurations || []),
...newNotificationConfig.QueueConfigurations,
];
]
}
if (newNotificationConfig.TopicConfigurations) {
mergedNotificationConfig.TopicConfigurations = [
...(currentNotifications.TopicConfigurations || []),
...newNotificationConfig.TopicConfigurations,
];
]
}
// 调用 API 创建 bucket notification
await putBucketNotifications(props.bucketName, mergedNotificationConfig);
await putBucketNotifications(props.bucketName, mergedNotificationConfig)
$message.success(t('Create Success'));
visible.value = false;
// 重置表单
formData.value = {
resourceName: '',
prefix: '',
suffix: '',
events: [],
};
// 触发成功事件,通知父组件刷新列表
emit('success');
} catch (error: any) {
console.error('创建 bucket notification 失败:', error);
$message.error(t('Create Failed'));
message.success(t('Create Success'))
visible.value = false
emit('success')
resetForm()
} catch (error) {
console.error('创建 bucket notification 失败:', error)
message.error(t('Create Failed'))
}
};
}
const handleCancel = () => {
// 取消逻辑
visible.value = false;
// 重置表单验证状态
formRef.value?.restoreValidation();
};
visible.value = false
resetForm()
}
// 获取arn列表
const arnList = ref<Array<{ label: string; value: string }>>([]);
getEventTargetArnList().then((res: string[]) => {
arnList.value = res.map((item: string) => {
return {
label: item,
value: item,
};
});
});
defineExpose({
open,
})
</script>
+27 -44
View File
@@ -1,18 +1,29 @@
<template>
<n-dropdown :options="options" trigger="click" @select="handleSelect">
<n-button :text="true" block>
<template #icon>
<Icon :name="currentLanguage.icon" />
</template>
{{ currentLanguage.text }}
</n-button>
</n-dropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-full justify-start gap-2 px-2">
<Icon :name="currentLanguage.icon" class="h-4 w-4" />
<span class="truncate">{{ currentLanguage.text }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40" align="start">
<DropdownMenuItem v-for="option in options" :key="option.key" @select="() => handleSelect(option.key)">
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup lang="ts">
import { Icon } from '#components';
import type { DropdownOption } from 'naive-ui';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale, setLocale } = useI18n();
@@ -23,45 +34,17 @@ const languageConfig = {
tr: { text: 'Türkçe', icon: 'ri:translate' },
} as const;
const options = [
{ label: 'English', key: 'en' },
{ label: '中文', key: 'zh' },
{ label: 'Türkçe', key: 'tr' },
];
const currentLanguage = computed(() => {
return languageConfig[locale.value as keyof typeof languageConfig] || languageConfig.en;
});
const options = ref<DropdownOption[]>([
{
label: 'English',
key: 'en',
},
{
label: '中文',
key: 'zh',
},
{
label: 'Türkçe',
key: 'tr',
},
]);
const handleSelect = async (key: string) => {
await setLocale(key as 'en' | 'zh' | 'tr');
};
</script>
<style scoped>
.language-switcher {
display: inline-block;
}
.language-select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
cursor: pointer;
outline: none;
}
.language-select:hover {
border-color: #999;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<Teleport to="body">
<div>
<AlertDialog
v-for="dialog in dialogs"
:key="dialog.id"
:open="dialog.open"
@update:open="value => controller.setOpen(dialog.id, value)"
>
<AlertDialogContent class="sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle v-if="dialog.title">{{ dialog.title }}</AlertDialogTitle>
<AlertDialogDescription v-if="dialog.content">
{{ dialog.content }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel v-if="dialog.negativeText" @click.prevent="() => handleNegative(dialog)">
{{ dialog.negativeText }}
</AlertDialogCancel>
<AlertDialogAction :class="positiveButtonClass(dialog)" @click.prevent="() => handlePositive(dialog)">
{{ dialog.positiveText || 'Confirm' }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { buttonVariants } from '@/components/ui/button'
import type { DialogInstance } from '@/lib/ui/dialog'
import { useDialogController } from '@/lib/ui/dialog'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
const controller = useDialogController()
const dialogs = computed(() => controller.dialogs.value)
const positiveButtonClass = (dialog: DialogInstance) => {
return cn(
buttonVariants({ variant: dialog.tone === 'destructive' || dialog.tone === 'warning' ? 'destructive' : 'default' }),
'w-full sm:w-auto'
)
}
const handleAction = async (
dialog: DialogInstance,
action?: DialogInstance['onPositiveClick'] | DialogInstance['onNegativeClick']
) => {
if (!action) {
controller.close(dialog.id)
return
}
try {
const result = await action()
if (result === false) return
controller.close(dialog.id)
} catch (error) {
console.error(error)
}
}
const handlePositive = (dialog: DialogInstance) => handleAction(dialog, dialog.onPositiveClick)
const handleNegative = (dialog: DialogInstance) => handleAction(dialog, dialog.onNegativeClick)
</script>
+21
View File
@@ -0,0 +1,21 @@
<template>
<slot />
<AppDialogHost />
<ClientOnly>
<Toaster position="top-right" :rich-colors="true" :close-button="true" />
</ClientOnly>
</template>
<script setup lang="ts">
import AppDialogHost from '@/components/providers/AppDialogHost.vue'
import { Toaster } from '@/components/ui/sonner'
import { createDialogController, dialogControllerKey } from '@/lib/ui/dialog'
import { createMessageApi, messageInjectionKey } from '@/lib/ui/message'
import { provide } from 'vue'
const dialogController = createDialogController()
const messageApi = createMessageApi()
provide(dialogControllerKey, dialogController)
provide(messageInjectionKey, messageApi)
</script>
-164
View File
@@ -1,164 +0,0 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';
import { useSidebarStore } from '~/store/sidebar';
import type { AppConfig, NavItem } from '~/types/app-config';
import type { SiteConfig } from '~/types/config';
const { t } = useI18n();
const appConfig = useAppConfig() as unknown as AppConfig;
const route = useRoute();
const sidebarStore = useSidebarStore();
const isCollapsed = computed(() => sidebarStore.isCollapsed);
// 安全地获取 siteConfig,如果失败则使用默认值
let siteConfig: SiteConfig;
try {
siteConfig = useNuxtApp().$siteConfig as SiteConfig;
} catch (error) {
console.warn('Failed to load siteConfig, using defaults:', error);
siteConfig = {
serverHost: window.location.origin,
api: { baseURL: `${window.location.origin}/rustfs/admin/v3` },
s3: {
endpoint: window.location.origin,
region: 'us-east-1',
accessKeyId: '',
secretAccessKey: '',
},
};
}
const toggleSidebar = () => {
sidebarStore.toggleSidebar();
};
// 缓存导航配置的生成逻辑,避免每次重新计算
const navOptions = computed(() => {
return appConfig.navs.map((nav: NavItem) => {
let item: {
key: string;
type?: string;
label: () => string | VNode;
icon?: () => VNode;
children?: any[];
} = {
key: nav.label,
label: () =>
nav.to
? nav.target
? h(
'a',
{
href: nav.to,
target: '_blank',
},
{ default: () => t(nav.label) }
)
: h(RouterLink, { to: nav.to }, { default: () => t(nav.label) })
: t(nav.label),
icon: nav.icon ? iconRender(nav.icon) : undefined,
type: nav.type,
};
if (nav['children']) {
item.children = nav['children'].map(child => {
return {
key: child.label,
type: 'item',
label: () => h(RouterLink, { to: child.to || '/' }, { default: () => t(child.label) }),
icon: child.icon ? iconRender(child.icon) : undefined,
};
});
}
return item;
});
});
// 直接使用计算属性,不需要 readonly 包装
const menuOptions = navOptions;
</script>
<template>
<n-layout-sider
bordered
class="min-h-full"
collapse-mode="width"
:collapsed-width="64"
:width="240"
:native-scrollbar="false"
:collapsed="isCollapsed"
v-if="route.path.startsWith('/auth') === false"
>
<div class="flex flex-col h-screen overflow-hidden gap-2 relative">
<div
class="border-b dark:border-neutral-800 flex flex-wrap h-16 items-center p-4"
:class="isCollapsed ? 'justify-center' : 'justify-between'"
>
<div>
<n-avatar v-if="isCollapsed" class="text-center text-2xl leading-none">
{{ appConfig.name.substring(0, 1) }}
</n-avatar>
<h2 v-else class="text-center text-2xl flex">
<img src="~/assets/logo.svg" class="max-w-28" alt="" />
<span class="sr-only">{{ appConfig.name }}</span>
</h2>
</div>
<div v-if="!isCollapsed" class="px-4 flex items-center -mr-4">
<Icon name="ri:menu-fold-fill" class="cursor-pointer text-xl" @click="toggleSidebar" :title="t('Collapse')" />
</div>
</div>
<div class="overflow-y-auto flex-1">
<n-menu
:indent="26"
:root-indent="12"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
class="flex-1"
/>
</div>
<div v-if="isCollapsed" class="w-full flex items-center justify-center py-4">
<Icon name="ri:menu-unfold-fill" class="cursor-pointer text-xl" @click="toggleSidebar" :title="t('Expand')" />
</div>
<div class="border-t dark:border-neutral-800 p-2" :class="{ 'flex justify-between items-center': !isCollapsed }">
<!-- 语言切换组件 -->
<div class="px-2 py-2">
<div v-if="isCollapsed" class="flex justify-center">
<n-tooltip placement="right" trigger="hover">
<template #trigger>
<div @click="toggleSidebar" class="cursor-pointer">
<Icon :name="isCollapsed ? 'ri:translate-2' : 'ri:translate'" class="text-xl" />
</div>
</template>
{{ t('Language') }}
</n-tooltip>
</div>
<language-switcher v-else />
</div>
<!-- 主题切换组件 -->
<div class="px-2 py-2">
<div v-if="isCollapsed" class="flex justify-center">
<n-tooltip placement="right" trigger="hover">
<template #trigger>
<div @click="toggleSidebar" class="cursor-pointer">
<Icon name="ri:contrast-2-line" class="text-xl" />
</div>
</template>
{{ t('Theme') }}
</n-tooltip>
</div>
<theme-switcher v-else />
</div>
</div>
<div class="sticky bottom-0 left-0 right-0">
<UserDropdown :isCollapsed="isCollapsed" />
</div>
</div>
</n-layout-sider>
</template>
+328 -111
View File
@@ -1,143 +1,360 @@
<template>
<n-modal
v-model:show="visible"
:mask-closable="false"
preset="card"
:title="t('Add Site Replication')"
class="max-w-screen-lg"
:segmented="{
content: true,
action: true,
}"
>
<n-card :title="t('Add Replication Site')">
<p>
{{ t('Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites') }}
</p>
<n-form ref="currentFormRef" :model="currentSite" :rules="rules">
<!-- 当前站点 -->
<n-flex style="margin-top: 16px">
<n-card :title="t('Current Site')">
<n-space direction="vertical">
<n-form-item :label="t('Site Name')" path="name">
<n-input v-model:value="currentSite.name" :placeholder="t('Site Name')" />
</n-form-item>
<n-form-item :label="t('Endpoint *')" path="endpoint">
<n-input v-model:value="currentSite.endpoint" :placeholder="t('Endpoint')" />
</n-form-item>
<n-form-item :label="t('Access Key *')" path="accessKey">
<n-input v-model:value="currentSite.accessKey" :placeholder="t('Access Key')" />
</n-form-item>
<n-form-item :label="t('Secret Key *')" path="secretKey">
<n-input type="password" v-model:value="currentSite.secretKey" :placeholder="t('Secret Key')" />
</n-form-item>
</n-space>
</n-card>
</n-flex>
<Dialog :open="visible" @update:open="handleOpenChange">
<DialogContent
class="max-w-4xl"
@pointerDownOutside.prevent
@interactOutside.prevent
@escapeKeyDown.prevent
>
<DialogHeader class="space-y-2 text-left">
<DialogTitle>{{ t('Add Site Replication') }}</DialogTitle>
<DialogDescription>
{{ t('Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites') }}
</DialogDescription>
</DialogHeader>
<!-- 远程站点 -->
<n-flex direction="vertical" style="margin-top: 16px">
<n-card :title="t('Remote Site')">
<n-space direction="vertical">
<n-dynamic-input :min="1" v-model:value="remoteSite" :on-create="onCreate">
<template #default="{ value }">
<n-grid x-gap="12" :cols="4">
<n-gi>
<n-form-item :label="t('Site Name')" path="name">
<n-input v-model:value="value.name" :placeholder="t('Site Name')" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item :label="t('Endpoint *')" path="endpoint">
<n-input v-model:value="value.endpoint" :placeholder="t('Endpoint')" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item :label="t('Access Key *')" path="accessKey">
<n-input v-model:value="value.accessKey" :placeholder="t('Access Key')" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item :label="t('Secret Key *')" path="secretKey">
<n-input type="password" v-model:value="value.secretKey" :placeholder="t('Secret Key')" />
</n-form-item>
</n-gi>
</n-grid>
</template>
</n-dynamic-input>
</n-space>
</n-card>
</n-flex>
</n-form>
<!-- 按钮 -->
<n-space justify="center" style="margin-top: 16px">
<n-button type="primary" @click="save">{{ t('Save') }}</n-button>
<n-button @click="cancel">{{ t('Cancel') }}</n-button>
</n-space>
</n-card>
</n-modal>
<div class="space-y-8">
<section class="space-y-4">
<h3 class="text-lg font-semibold">{{ t('Current Site') }}</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="current-site-name">{{ t('Site Name') }}</Label>
<Input
id="current-site-name"
v-model="currentSite.name"
:placeholder="t('Site Name')"
/>
</div>
<div class="space-y-2 sm:col-span-2">
<Label for="current-site-endpoint">{{ t('Endpoint *') }}</Label>
<Input
id="current-site-endpoint"
v-model="currentSite.endpoint"
:placeholder="t('Endpoint')"
/>
<p v-if="currentErrors.endpoint" class="text-sm text-destructive">
{{ currentErrors.endpoint }}
</p>
</div>
<div class="space-y-2">
<Label for="current-site-access">{{ t('Access Key *') }}</Label>
<Input
id="current-site-access"
v-model="currentSite.accessKey"
:placeholder="t('Access Key')"
/>
<p v-if="currentErrors.accessKey" class="text-sm text-destructive">
{{ currentErrors.accessKey }}
</p>
</div>
<div class="space-y-2">
<Label for="current-site-secret">{{ t('Secret Key *') }}</Label>
<Input
id="current-site-secret"
v-model="currentSite.secretKey"
type="password"
:placeholder="t('Secret Key')"
/>
<p v-if="currentErrors.secretKey" class="text-sm text-destructive">
{{ currentErrors.secretKey }}
</p>
</div>
</div>
</section>
<section class="space-y-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-lg font-semibold">{{ t('Remote Site') }}</h3>
<Button
type="button"
variant="secondary"
class="inline-flex items-center gap-2 self-start"
@click="addRemoteSite"
>
<Icon class="size-4" name="ri:add-line" />
{{ t('Add Site') }}
</Button>
</div>
<div class="space-y-4">
<div
v-for="(site, index) in remoteSite"
:key="index"
class="space-y-4 rounded-lg border p-4"
>
<div class="flex items-start justify-between">
<p class="text-sm font-medium text-muted-foreground">
{{ t('Remote Site') }} {{ index + 1 }}
</p>
<Button
v-if="remoteSite.length > 1"
type="button"
size="icon"
variant="ghost"
class="-mr-2 h-8 w-8"
:aria-label="t('Delete')"
@click="removeRemoteSite(index)"
>
<Icon class="size-4" name="ri:close-line" />
</Button>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label :for="`remote-site-name-${index}`">{{ t('Site Name') }}</Label>
<Input
:id="`remote-site-name-${index}`"
v-model="site.name"
:placeholder="t('Site Name')"
/>
</div>
<div class="space-y-2 sm:col-span-2">
<Label :for="`remote-site-endpoint-${index}`">{{ t('Endpoint *') }}</Label>
<Input
:id="`remote-site-endpoint-${index}`"
v-model="site.endpoint"
:placeholder="t('Endpoint')"
/>
<p v-if="remoteErrors[index]?.endpoint" class="text-sm text-destructive">
{{ remoteErrors[index].endpoint }}
</p>
</div>
<div class="space-y-2">
<Label :for="`remote-site-access-${index}`">{{ t('Access Key *') }}</Label>
<Input
:id="`remote-site-access-${index}`"
v-model="site.accessKey"
:placeholder="t('Access Key')"
/>
<p v-if="remoteErrors[index]?.accessKey" class="text-sm text-destructive">
{{ remoteErrors[index].accessKey }}
</p>
</div>
<div class="space-y-2">
<Label :for="`remote-site-secret-${index}`">{{ t('Secret Key *') }}</Label>
<Input
:id="`remote-site-secret-${index}`"
v-model="site.secretKey"
type="password"
:placeholder="t('Secret Key')"
/>
<p v-if="remoteErrors[index]?.secretKey" class="text-sm text-destructive">
{{ remoteErrors[index].secretKey }}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="flex flex-col gap-2 pt-2 sm:flex-row sm:justify-center">
<Button type="button" variant="outline" @click="cancel">{{ t('Cancel') }}</Button>
<Button type="button" @click="save">{{ t('Save') }}</Button>
</div>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
<script setup lang="ts">
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n();
interface SiteFormValue {
name: string
endpoint: string
accessKey: string
secretKey: string
}
const currentSite = ref({
interface SiteFormErrors {
endpoint: string
accessKey: string
secretKey: string
}
const { t } = useI18n()
const visible = ref(false)
const currentSite = reactive<SiteFormValue>({
name: '',
endpoint: 'http://127.0.0.1:7000',
accessKey: 'rusyfsadmin',
secretKey: '',
});
})
const remoteSite = ref([
const remoteSite = ref<SiteFormValue[]>([
{
name: '',
endpoint: '',
accessKey: '',
secretKey: '',
},
]);
])
const onCreate = () => {
return {
name: '',
const currentErrors = reactive<SiteFormErrors>({
endpoint: '',
accessKey: '',
secretKey: '',
})
const remoteErrors = ref<SiteFormErrors[]>([
{
endpoint: '',
accessKey: '',
secretKey: '',
};
};
},
])
const rules = {
endpoint: [{ required: true, message: t('Endpoint is required'), trigger: 'blur' }],
accessKey: [{ required: true, message: t('Access Key is required'), trigger: 'blur' }],
secretKey: [{ required: true, message: t('Secret Key is required'), trigger: 'blur' }],
};
const createRemoteEntry = (): SiteFormValue => ({
name: '',
endpoint: '',
accessKey: '',
secretKey: '',
})
const createErrorState = (): SiteFormErrors => ({
endpoint: '',
accessKey: '',
secretKey: '',
})
const handleOpenChange = (value: boolean) => {
visible.value = value
}
const addRemoteSite = () => {
remoteSite.value.push(createRemoteEntry())
remoteErrors.value.push(createErrorState())
}
const removeRemoteSite = (index: number) => {
if (remoteSite.value.length <= 1) return
remoteSite.value.splice(index, 1)
remoteErrors.value.splice(index, 1)
}
watch(
() => remoteSite.value.length,
newLength => {
while (remoteErrors.value.length < newLength) {
remoteErrors.value.push(createErrorState())
}
while (remoteErrors.value.length > newLength) {
remoteErrors.value.pop()
}
}
)
watch(
() => currentSite.endpoint,
value => {
if (value?.trim()) {
currentErrors.endpoint = ''
}
}
)
watch(
() => currentSite.accessKey,
value => {
if (value?.trim()) {
currentErrors.accessKey = ''
}
}
)
watch(
() => currentSite.secretKey,
value => {
if (value?.trim()) {
currentErrors.secretKey = ''
}
}
)
watch(
remoteSite,
sites => {
sites.forEach((site, index) => {
const errors = remoteErrors.value[index] ?? createErrorState()
if (site.endpoint?.trim()) {
errors.endpoint = ''
}
if (site.accessKey?.trim()) {
errors.accessKey = ''
}
if (site.secretKey?.trim()) {
errors.secretKey = ''
}
remoteErrors.value[index] = errors
})
},
{ deep: true }
)
const validate = () => {
let valid = true
if (!currentSite.endpoint.trim()) {
currentErrors.endpoint = t('Endpoint is required')
valid = false
}
if (!currentSite.accessKey.trim()) {
currentErrors.accessKey = t('Access Key is required')
valid = false
}
if (!currentSite.secretKey.trim()) {
currentErrors.secretKey = t('Secret Key is required')
valid = false
}
remoteSite.value.forEach((site, index) => {
const errors = remoteErrors.value[index] ?? createErrorState()
if (!site.endpoint.trim()) {
errors.endpoint = t('Endpoint is required')
valid = false
}
if (!site.accessKey.trim()) {
errors.accessKey = t('Access Key is required')
valid = false
}
if (!site.secretKey.trim()) {
errors.secretKey = t('Secret Key is required')
valid = false
}
remoteErrors.value[index] = errors
})
return valid
}
const currentFormRef = ref(null);
const save = () => {
// Save logic here
console.log('Current Site:', currentSite.value);
console.log('Remote Site:', remoteSite.value);
};
if (!validate()) {
return
}
console.log('Current Site:', { ...currentSite })
console.log('Remote Site:', remoteSite.value.map(site => ({ ...site })))
visible.value = false
}
const cancel = () => {
// Cancel logic here
};
visible.value = false
}
const visible = ref(false);
const open = () => {
visible.value = true;
};
visible.value = true
}
defineExpose({
open,
});
})
</script>
<style scoped>
:deep(.n-dynamic-input-item__action) {
align-self: center !important;
}
</style>
+30 -25
View File
@@ -1,17 +1,34 @@
<template>
<n-dropdown :options="themeOptions" trigger="click" @select="handleSelect">
<n-button :text="true" block>
<template #icon>
<Icon :name="themeIcon" />
</template>
{{ t(themeName) }}
</n-button>
</n-dropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-full justify-start gap-2 px-2">
<Icon :name="themeIcon" class="h-4 w-4" />
<span class="truncate">{{ t(themeName) }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40" align="start">
<DropdownMenuItem
v-for="option in themeOptions"
:key="option.key"
@select="() => handleSelect(option.key)"
>
<Icon :name="option.icon" class="mr-2 h-4 w-4" />
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup lang="ts">
import { Icon } from '#components';
import { useColorMode } from '@vueuse/core';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -40,23 +57,11 @@ const themeIcon = computed(() => {
}
});
const themeOptions = [
{
label: () =>
h('div', { class: 'flex items-center' }, [h(Icon, { name: 'ri:sun-fill', class: 'mr-2' }), t('Light')]),
key: 'light',
},
{
label: () =>
h('div', { class: 'flex items-center' }, [h(Icon, { name: 'ri:moon-fill', class: 'mr-2' }), t('Dark')]),
key: 'dark',
},
{
label: () =>
h('div', { class: 'flex items-center' }, [h(Icon, { name: 'ri:contrast-2-line', class: 'mr-2' }), t('Auto')]),
key: 'auto',
},
];
const themeOptions = computed(() => [
{ label: t('Light'), key: 'light', icon: 'ri:sun-fill' },
{ label: t('Dark'), key: 'dark', icon: 'ri:moon-fill' },
{ label: t('Auto'), key: 'auto', icon: 'ri:contrast-2-line' },
]);
const handleSelect = (key: string) => {
if (key === 'light' || key === 'dark' || key === 'auto') {
+36 -35
View File
@@ -1,37 +1,44 @@
<template>
<n-dropdown :options="options" placement="right-end" @select="handleDropdownClick">
<div class="flex items-center border-t dark:border-neutral-800 p-4">
<div class="rounded-full h-8 w-8 object-cover bg-gray-100 border overflow-hidden">
<img class="min-h-full" size="small" src="~/assets/img/rustfs.png" />
</div>
<template v-if="!isCollapsed">
<span class="px-2">{{ t('RustFS') }}</span>
<Icon name="ri:more-2-line" class="ml-auto text-xl" />
</template>
</div>
</n-dropdown>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="w-full items-center justify-between gap-2 rounded-none border-t border-sidebar-border px-4 py-3 text-left"
>
<div class="flex items-center gap-3">
<span class="flex h-9 w-9 items-center justify-center rounded-full border bg-muted">
<img src="~/assets/img/rustfs.png" alt="RustFS" class="h-8 w-8 rounded-full object-cover" />
</span>
<span v-if="!isCollapsed" class="text-sm font-medium">{{ t('RustFS') }}</span>
</div>
<Icon v-if="!isCollapsed" name="ri:more-2-line" class="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48" align="end" side="top">
<DropdownMenuItem @select="handleLogout">
<Icon name="ri:logout-box-r-line" class="h-4 w-4" />
<span>{{ t('Logout') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script lang="ts" setup>
import { Icon } from '#components';
import { defineProps, withDefaults } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { defineProps, toRef, withDefaults } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { logout } = useAuth();
const router = useRouter();
const handleLogout = async () => {
await logout();
router.push('/auth/login');
};
const handleDropdownClick = (key: string) => {
if (key === 'logout') {
handleLogout();
}
};
const props = withDefaults(
defineProps<{
isCollapsed?: boolean;
@@ -41,16 +48,10 @@ const props = withDefaults(
}
);
const options = [
// {
// label: t('Profile'),
// key: 'profile',
// icon: () => icon('ri:account-box-line')
// },
{
label: t('Logout'),
key: 'logout',
icon: () => icon('ri:logout-box-r-line'),
},
];
const isCollapsed = toRef(props, 'isCollapsed');
const handleLogout = async () => {
await logout();
router.push('/auth/login');
};
</script>
+202 -117
View File
@@ -1,145 +1,230 @@
<template>
<div>
<n-card>
<n-form ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between" v-if="!editStatus">
<n-form-item class="!w-64" label="" path="name">
<n-input :placeholder="t('Search User')" @input="filterName" />
</n-form-item>
<div class="space-y-4">
<Card>
<CardContent class="space-y-4 pt-6">
<div v-if="!editStatus" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full sm:max-w-xs">
<Input v-model="searchTerm" :placeholder="t('Search User')" />
</div>
<Button type="button" variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
<Icon class="size-4" name="ri:add-line" />
{{ t('Edit User') }}
</Button>
</div>
<div v-else class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="flex w-full flex-col gap-2">
<Label class="text-sm font-medium">{{ t('Select user group members') }}</Label>
<Popover v-model:open="memberSelectorOpen">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
class="min-h-10 justify-between gap-2"
:aria-label="t('Select user group members')"
>
<span class="truncate">
{{
selectedUserLabels.length
? selectedUserLabels.join(', ')
: t('Select user group members')
}}
</span>
<Icon class="size-4 text-muted-foreground" name="ri:arrow-down-s-line" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-0" align="start">
<Command>
<CommandInput :placeholder="t('Search User')" />
<CommandList>
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="option in users"
:key="option.value"
:value="option.label"
@select="() => toggleMember(option.value)"
>
<Icon
name="ri:check-line"
class="mr-2 size-4"
:class="members.includes(option.value) ? 'opacity-100' : 'opacity-0'"
/>
<span>{{ option.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div v-if="members.length" class="flex flex-wrap gap-2">
<Badge v-for="value in members" :key="value" variant="secondary">{{ value }}</Badge>
</div>
</div>
<div class="flex items-center gap-2 sm:self-start">
<Button type="button" variant="secondary" @click="changeMembers">{{ t('Submit') }}</Button>
</div>
</div>
</CardContent>
</Card>
<n-space>
<NFlex>
<NButton secondary @click="editStatus = true">
<template #icon>
<Icon name="ri:add-line"></Icon>
</template>
{{ t('Edit User') }}
</NButton>
</NFlex>
</n-space>
</n-flex>
<n-flex justify="space-between" v-else>
<n-form-item class="!w-96" :label="t('Select user group members')" path="members">
<n-select v-model:value="members" filterable multiple :options="users" />
</n-form-item>
<n-space>
<NFlex>
<NButton secondary @click="changeMebers">{{ t('Submit') }}</NButton>
</NFlex>
</n-space>
</n-flex>
</n-form>
</n-card>
<n-data-table
class="my-4"
ref="tableRef"
:columns="columns"
:data="listData"
:pagination="false"
:bordered="false"
/>
<Table class="overflow-hidden rounded-lg border">
<TableHeader>
<TableRow>
<TableHead>{{ t('Name') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="filteredMembers.length">
<TableRow v-for="member in filteredMembers" :key="member">
<TableCell class="font-medium">{{ member }}</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell class="text-center" colspan="1">
<p class="py-6 text-sm text-muted-foreground">{{ t('No Data') }}</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</template>
<script setup lang="ts">
import { type DataTableColumns, type DataTableInst, NButton, NSpace } from 'naive-ui';
const { listUsers } = useUsers();
const { updateGroupMembers } = useGroups();
const { t } = useI18n();
import { Icon } from '#components'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const messge = useMessage();
const props = defineProps({
interface UserOption {
label: string
value: string
}
const props = defineProps<{
group: {
type: Object,
required: true,
name: string
members: string[]
}
}>()
const emit = defineEmits<{ (e: 'search'): void }>()
const { listUsers } = useUsers()
const { updateGroupMembers } = useGroups()
const { t } = useI18n()
const message = useMessage()
const editStatus = ref(false)
const searchTerm = ref('')
const memberSelectorOpen = ref(false)
const users = ref<UserOption[]>([])
const members = ref<string[]>([])
const userLabelMap = computed(() => {
return users.value.reduce<Record<string, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {})
})
const selectedUserLabels = computed(() => members.value.map(value => userLabelMap.value[value] ?? value))
const filteredMembers = computed(() => {
const keyword = searchTerm.value.trim().toLowerCase()
const source = props.group?.members ?? []
if (!keyword) return source
return source.filter(item => item.toLowerCase().includes(keyword))
})
watch(
() => props.group.members,
newMembers => {
members.value = [...(newMembers ?? [])]
if (!newMembers?.length) {
memberSelectorOpen.value = false
}
},
});
{ immediate: true }
)
const searchForm = reactive({
name: '',
});
interface RowData {
name: string;
}
const columns: DataTableColumns<RowData> = [
{
title: t('Name'),
align: 'left',
key: 'name',
filter(value, row) {
return !!row.name.includes(value.toString());
},
},
];
// 搜索过滤
const tableRef = ref<DataTableInst>();
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
name: [value],
});
}
const listData = computed(() => {
return (
props.group.members.map((item: string) => {
return {
name: item,
};
}) || []
);
});
/********************编辑****************/
const editStatus = ref(false);
// 用户列表
const users = ref<any[]>([]);
const emit = defineEmits<{
(e: 'search'): void;
}>();
const getUserList = async () => {
const res = await listUsers();
users.value = Object.entries(res).map(([username, info]) => ({
label: username,
value: username,
...(typeof info === 'object' ? info : {}), // 展开用户信息
}));
};
getUserList();
const members = ref([...props.group.members]);
const changeMebers = async () => {
try {
// 删除不存在的
const nowRemoveMembers = props.group.members.filter((item: string) => {
return !members.value.includes(item);
});
const res = await listUsers()
users.value = Object.entries(res ?? {}).map(([username, info]) => ({
label: username,
value: username,
...(typeof info === 'object' ? info : {}),
}))
}
catch (error) {
message.error(t('Failed to get data'))
}
}
onMounted(() => {
getUserList()
})
const startEditing = () => {
members.value = [...(props.group.members ?? [])]
memberSelectorOpen.value = false
editStatus.value = true
}
watch(
() => props.group.name,
() => {
editStatus.value = false
memberSelectorOpen.value = false
}
)
const toggleMember = (value: string) => {
if (members.value.includes(value)) {
members.value = members.value.filter(item => item !== value)
}
else {
members.value = [...members.value, value]
}
}
const changeMembers = async () => {
try {
const currentMembers = props.group.members ?? []
const nowRemoveMembers = currentMembers.filter(item => !members.value.includes(item))
if (nowRemoveMembers.length) {
await updateGroupMembers({
group: props.group.name,
members: nowRemoveMembers,
isRemove: true,
groupStatus: 'enabled',
});
})
}
// 修改组的成员
await updateGroupMembers({
group: props.group.name,
members: members.value,
isRemove: false,
groupStatus: 'enabled',
});
})
messge.success('修改成功');
editStatus.value = false;
emit('search');
} catch {
messge.error('修改失败');
message.success('修改成功')
editStatus.value = false
memberSelectorOpen.value = false
emit('search')
}
};
catch (error) {
message.error('修改失败')
}
}
</script>
<style lang="scss" scoped></style>
+149 -135
View File
@@ -1,36 +1,82 @@
<template>
<div>
<n-form class="mb-4 mt-2" ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between">
<n-form-item label="" path="name">
<n-input :placeholder="t('Search User Group')" @input="filterName" />
</n-form-item>
<n-space>
<NFlex>
<!-- <NButton :disabled="!checkedKeys.length" secondary @click="deleteByList">
<template #icon>
<Icon name="ri:delete-bin-5-line"></Icon>
</template>
删除选中项
</NButton> -->
<NButton :disabled="!checkedKeys.length" secondary @click="allocationPolicy">
<template #icon>
<Icon name="ri:group-2-fill"></Icon>
</template>
{{ t('Assign Policy') }}
</NButton>
<NButton secondary @click="addUserGroup">
<template #icon>
<Icon name="ri:add-line"></Icon>
</template>
{{ t('Add User Group') }}
</NButton>
</NFlex>
</n-space>
</n-flex>
</n-form>
<n-data-table ref="tableRef" :columns="columns" :data="listData" :pagination="false" :bordered="true" max-height="calc(100vh - 320px)" :row-key="rowKey"
@update:checked-row-keys="handleCheck" />
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex w-full max-w-md items-center gap-2">
<Input v-model="searchTerm" :placeholder="t('Search User Group')" />
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" :disabled="!checkedKeys.length" @click="allocationPolicy">
<Icon class="size-4" name="ri:group-2-fill" />
{{ t('Assign Policy') }}
</Button>
<Button type="button" variant="secondary" @click="addUserGroup">
<Icon class="size-4" name="ri:add-line" />
{{ t('Add User Group') }}
</Button>
</div>
</div>
<Table class="overflow-hidden rounded-lg border">
<TableHeader>
<TableRow>
<TableHead class="w-10">
<Checkbox
:checked="headerCheckboxState"
:disabled="!filteredGroups.length"
aria-label="Select all groups"
@update:checked="toggleAll"
/>
</TableHead>
<TableHead class="w-full">{{ t('Name') }}</TableHead>
<TableHead class="w-40 text-center">{{ t('Actions') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="filteredGroups.length">
<TableRow v-for="row in filteredGroups" :key="rowKey(row)">
<TableCell class="w-10">
<Checkbox
:checked="checkedKeys.includes(rowKey(row))"
aria-label="Select group"
@update:checked="value => toggleRow(rowKey(row), value)"
/>
</TableCell>
<TableCell class="font-medium">{{ row.name }}</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-2">
<Button type="button" size="sm" variant="secondary" @click="openEditItem(row)">
<Icon class="size-4" name="ri:edit-2-line" />
{{ t('Edit') }}
</Button>
<AlertDialog>
<AlertDialogTrigger as-child>
<Button type="button" size="sm" variant="secondary">
<Icon class="size-4" name="ri:delete-bin-5-line" />
{{ t('Delete') }}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('Confirm Delete') }}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('Cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="() => deleteItem(row)">
{{ t('Delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell class="text-center" colspan="3">
<p class="py-6 text-sm text-muted-foreground">{{ t('No Data') }}</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
<users-group-edit ref="editItemRef"></users-group-edit>
<users-group-new v-model:visible="newItemVisible" @search="getDataList" ref="newItemRef"></users-group-new>
<users-group-set-policies-mutiple :checkedKeys="checkedKeys" @changePoliciesSuccess="changePoliciesSuccess" ref="policiesRef"></users-group-set-policies-mutiple>
@@ -39,116 +85,68 @@
<script setup lang="ts">
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import {
type DataTableColumns,
type DataTableInst,
type DataTableRowKey,
NButton,
NPopconfirm,
NSpace,
} from 'naive-ui'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// import { groupEdit, newGroup, setPoliciesMutiple } from '../components';
const messge = useMessage()
const { t } = useI18n()
const { $api } = useNuxtApp()
const dialog = useDialog()
const message = useMessage()
const group = useGroups()
const searchForm = reactive({
name: '',
})
interface RowData {
interface GroupRow {
name: string
}
const columns: DataTableColumns<RowData> = [
{
type: 'selection',
},
{
title: t('Name'),
align: 'left',
key: 'name',
filter(value, row) {
return !!row.name.includes(value.toString())
},
},
{
title: t('Actions'),
key: 'actions',
align: 'center',
width: 180,
render: (row: any) => {
return h(
NSpace,
{
justify: 'center',
},
{
default: () => [
h(
NButton,
{
size: 'small',
secondary: true,
onClick: () => openEditItem(row),
},
{
default: () => t('Edit'),
icon: () => h(Icon, { name: 'ri:edit-2-line' }),
}
),
h(
NPopconfirm,
{ onPositiveClick: () => deleteItem(row) },
{
default: () => t('Confirm Delete'),
trigger: () =>
h(
NButton,
{ size: 'small', secondary: true },
{
default: () => t('Delete'),
icon: () => h(Icon, { name: 'ri:delete-bin-5-line' }),
}
),
}
),
],
}
)
},
},
]
const searchTerm = ref('')
const listData = ref<GroupRow[]>([])
const checkedKeys = ref<string[]>([])
// 搜索过滤
const tableRef = ref<DataTableInst>()
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
name: [value],
})
}
const listData = ref<any[]>([])
const filteredGroups = computed(() => {
const keyword = searchTerm.value.trim().toLowerCase()
if (!keyword) return listData.value
return listData.value.filter(item => item.name.toLowerCase().includes(keyword))
})
const allSelected = computed(
() => filteredGroups.value.length > 0 && filteredGroups.value.every(row => checkedKeys.value.includes(row.name))
)
const headerCheckboxState = computed(() => {
if (!filteredGroups.value.length) return false
if (allSelected.value) return true
if (checkedKeys.value.length) return 'indeterminate'
return false
})
onMounted(() => {
getDataList()
})
// 获取数据
const getDataList = async () => {
try {
const res = await group.listGroup()
listData.value =
res?.map((item: string) => {
return {
name: item,
}
}) || []
checkedKeys.value = []
res?.map((item: string) => ({
name: item,
})) || []
const existingKeys = new Set(listData.value.map(item => item.name))
checkedKeys.value = checkedKeys.value.filter(key => existingKeys.has(key))
} catch (error) {
message.error(t('Failed to get data'))
}
@@ -173,17 +171,17 @@ const changePoliciesSuccess = () => {
}
/** **********************************修改 */
const editItemRef = ref()
function openEditItem(row: any) {
function openEditItem(row: GroupRow) {
editItemRef.value.openDialog(row)
}
/** ***********************************删除 */
async function deleteItem(row: any) {
async function deleteItem(row: GroupRow) {
try {
// 获取组的成员
const info = await group.getGroup(row.name)
if (info.members.length) {
messge.error('请先清空组成员')
message.error('请先清空组成员')
return
}
// 清空组的成员
@@ -194,41 +192,57 @@ async function deleteItem(row: any) {
groupStatus: 'enabled',
})
message.success(t('Delete Success'))
getDataList()
await getDataList()
} catch (error) {
message.error(t('Delete Failed'))
}
}
/** ************************************批量删除 */
function rowKey(row: any): string {
function rowKey(row: GroupRow): string {
return row.name
}
const checkedKeys = ref<DataTableRowKey[]>([])
function handleCheck(keys: DataTableRowKey[]) {
checkedKeys.value = keys
return checkedKeys
}
function deleteByList() {
dialog.error({
title: t('Warning'),
content: t('Are you sure you want to delete all selected user groups?'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: () => {
onPositiveClick: async () => {
if (!checkedKeys.value.length) {
message.error(t('Please select at least one item'))
return
}
checkedKeys.value.forEach(async (element: any) => {
const res = await group.removeGroup(element)
})
getDataList()
try {
await Promise.all(checkedKeys.value.map(element => group.removeGroup(element)))
checkedKeys.value = []
message.success(t('Delete Success'))
await getDataList()
} catch (error) {
message.error(t('Delete Failed'))
}
},
})
}
function toggleAll(value: boolean | 'indeterminate') {
if (value === true || value === 'indeterminate') {
checkedKeys.value = filteredGroups.value.map(item => item.name)
} else if (value === false) {
checkedKeys.value = []
}
}
function toggleRow(key: string, value: boolean | 'indeterminate') {
if (value === true || value === 'indeterminate') {
if (!checkedKeys.value.includes(key)) {
checkedKeys.value = [...checkedKeys.value, key]
}
} else {
checkedKeys.value = checkedKeys.value.filter(item => item !== key)
}
}
</script>
<style lang="scss" scoped></style>
+150 -117
View File
@@ -1,35 +1,96 @@
<template>
<div>
<n-form class="mb-4 mt-2" ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between">
<n-form-item label="" path="name">
<n-input :placeholder="t('Search Access User')" @input="filterName" />
</n-form-item>
<n-space>
<NFlex>
<NButton :disabled="!checkedKeys.length" secondary @click="deleteByList">
<template #icon>
<Icon name="ri:delete-bin-5-line"></Icon>
</template>
{{ t('Delete Selected') }}
</NButton>
<NButton :disabled="!checkedKeys.length" secondary @click="addToGroup">
<template #icon>
<Icon name="ri:group-2-fill"></Icon>
</template>
{{ t('Add to Group') }}
</NButton>
<NButton secondary @click="addUserItem">
<template #icon>
<Icon name="ri:add-line"></Icon>
</template>
{{ t('Add User') }}
</NButton>
</NFlex>
</n-space>
</n-flex>
</n-form>
<n-data-table ref="tableRef" :columns="columns" :data="listData" :pagination="false" :bordered="true" :row-key="rowKey" @update:checked-row-keys="handleCheck" />
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex w-full max-w-md items-center gap-2">
<Input v-model="searchTerm" :placeholder="t('Search Access User')" />
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
:disabled="!checkedKeys.length"
@click="deleteByList"
>
<Icon class="size-4" name="ri:delete-bin-5-line" />
{{ t('Delete Selected') }}
</Button>
<Button
type="button"
variant="secondary"
:disabled="!checkedKeys.length"
@click="addToGroup"
>
<Icon class="size-4" name="ri:group-2-fill" />
{{ t('Add to Group') }}
</Button>
<Button type="button" variant="secondary" @click="addUserItem">
<Icon class="size-4" name="ri:add-line" />
{{ t('Add User') }}
</Button>
</div>
</div>
<Table class="overflow-hidden rounded-lg border">
<TableHeader>
<TableRow>
<TableHead class="w-10">
<Checkbox
:checked="headerCheckboxState"
:disabled="!filteredUsers.length"
aria-label="Select all users"
@update:checked="toggleAll"
/>
</TableHead>
<TableHead class="w-full">{{ t('Name') }}</TableHead>
<TableHead class="w-40 text-center">{{ t('Actions') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="filteredUsers.length">
<TableRow v-for="row in filteredUsers" :key="rowKey(row)">
<TableCell class="w-10">
<Checkbox
:checked="checkedKeys.includes(rowKey(row))"
aria-label="Select user"
@update:checked="value => toggleRow(rowKey(row), value)"
/>
</TableCell>
<TableCell class="font-medium">{{ row.accessKey }}</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-2">
<Button type="button" size="sm" variant="secondary" @click="openEditItem(row)">
<Icon class="size-4" name="ri:edit-2-line" />
{{ t('Edit') }}
</Button>
<AlertDialog>
<AlertDialogTrigger as-child>
<Button type="button" size="sm" variant="secondary">
<Icon class="size-4" name="ri:delete-bin-5-line" />
{{ t('Delete') }}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('Confirm Delete') }}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('Cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="() => deleteItem(row)">
{{ t('Delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell class="text-center" colspan="3">
<p class="py-6 text-sm text-muted-foreground">{{ t('No Data') }}</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
<users-user-new @search="getDataList" ref="newItemRef"></users-user-new>
<users-user-edit @search="getDataList" :checkedKeys="checkedKeys" ref="editItemRef"></users-user-edit>
</div>
@@ -37,99 +98,54 @@
<script setup lang="ts">
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import {
type DataTableColumns,
type DataTableInst,
type DataTableRowKey,
NButton,
NPopconfirm,
NSpace,
} from 'naive-ui'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { $api } = useNuxtApp()
const { listUsers, deleteUser } = useUsers()
const dialog = useDialog()
const message = useMessage()
const searchForm = reactive({
accessKey: '',
})
interface RowData {
interface UserRow {
accessKey: string
[key: string]: unknown
}
const columns: DataTableColumns<RowData> = [
{
type: 'selection',
},
{
title: t('Name'),
align: 'left',
key: 'accessKey',
filter(value, row) {
return !!row.accessKey.includes(value.toString())
},
},
{
title: t('Actions'),
key: 'actions',
align: 'center',
width: 180,
render: (row: any) => {
return h(
NSpace,
{
justify: 'center',
},
{
default: () => [
h(
NButton,
{
size: 'small',
secondary: true,
onClick: () => openEditItem(row),
},
{
default: () => t('Edit'),
icon: () => h(Icon, { name: 'ri:edit-2-line' }),
}
),
h(
NPopconfirm,
{ onPositiveClick: () => deleteItem(row) },
{
default: () => t('Confirm Delete'),
trigger: () =>
h(
NButton,
{ size: 'small', secondary: true },
{
default: () => t('Delete'),
icon: () => h(Icon, { name: 'ri:delete-bin-5-line' }),
}
),
}
),
],
}
)
},
},
]
const searchTerm = ref('')
const listData = ref<UserRow[]>([])
const checkedKeys = ref<string[]>([])
// 搜索过滤
const tableRef = ref<DataTableInst>()
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
accessKey: [value],
})
}
const listData = ref<any[]>([])
const filteredUsers = computed(() => {
const keyword = searchTerm.value.trim().toLowerCase()
if (!keyword) return listData.value
return listData.value.filter(item => item.accessKey.toLowerCase().includes(keyword))
})
const allSelected = computed(
() => filteredUsers.value.length > 0 && filteredUsers.value.every(row => checkedKeys.value.includes(row.accessKey))
)
const headerCheckboxState = computed(() => {
if (!filteredUsers.value.length) return false
if (allSelected.value) return true
if (checkedKeys.value.length) return 'indeterminate'
return false
})
onMounted(() => {
getDataList()
@@ -144,6 +160,8 @@ const getDataList = async () => {
accessKey: username, // 添加用户名
...(typeof info === 'object' ? info : {}), // 展开用户信息
}))
const existingKeys = new Set(listData.value.map(item => item.accessKey))
checkedKeys.value = checkedKeys.value.filter(key => existingKeys.has(key))
} catch (error) {
message.error(t('Failed to get data'))
}
@@ -167,7 +185,7 @@ function openEditItem(row: any) {
/** ***********************************删除 */
async function deleteItem(row: any) {
try {
const res = await deleteUser(row.accessKey)
await deleteUser(row.accessKey)
message.success(t('Delete Success'))
getDataList()
} catch (error) {
@@ -180,11 +198,6 @@ function rowKey(row: any): string {
return row.accessKey
}
const checkedKeys = ref<DataTableRowKey[]>([])
function handleCheck(keys: DataTableRowKey[]) {
checkedKeys.value = keys
return checkedKeys
}
function deleteByList() {
dialog.error({
title: t('Warning'),
@@ -198,7 +211,7 @@ function deleteByList() {
}
try {
// 循环遍历删除
await Promise.all(checkedKeys.value.map(item => deleteUser(item as string)))
await Promise.all(checkedKeys.value.map(item => deleteUser(item)))
checkedKeys.value = []
message.success(t('Delete Success'))
nextTick(() => {
@@ -210,6 +223,26 @@ function deleteByList() {
},
})
}
function toggleAll(value: boolean | 'indeterminate') {
if (value === true) {
checkedKeys.value = filteredUsers.value.map(item => item.accessKey)
} else if (value === false) {
checkedKeys.value = []
} else if (value === 'indeterminate') {
checkedKeys.value = filteredUsers.value.map(item => item.accessKey)
}
}
function toggleRow(key: string, value: boolean | 'indeterminate') {
if (value === true || value === 'indeterminate') {
if (!checkedKeys.value.includes(key)) {
checkedKeys.value = [...checkedKeys.value, key]
}
} else {
checkedKeys.value = checkedKeys.value.filter(item => item !== key)
}
}
</script>
<style lang="scss" scoped></style>