Merge pull request #22 from rustfs/codex/migrate-ui-framework-to-shadcn-vue-i7qnxy

refactor: migrate events management ui to shadcn
This commit is contained in:
安正超
2025-10-25 22:26:59 +08:00
committed by GitHub
25 changed files with 1979 additions and 1186 deletions
+7 -38
View File
@@ -1,25 +1,18 @@
<template>
<div :class="isDark ? 'dark' : ''">
<n-config-provider :theme="theme" :locale="naiveLocale" :theme-overrides="themeOverrides" :date-locale="dateLocale">
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-config-provider>
<div class="min-h-screen" :class="{ dark: isDark }">
<AppUiProvider>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</AppUiProvider>
</div>
</template>
<script lang="ts" setup>
import AppUiProvider from '@/components/providers/AppUiProvider.vue';
import { useColorMode } from '@vueuse/core';
import { darkTheme, dateZhCN, enUS, zhCN } from 'naive-ui';
import { computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { themeOverrides } from '~/config/theme';
const { system, store } = useColorMode();
const { locale } = useI18n();
@@ -28,30 +21,6 @@ const isDark = computed(() => {
return store.value === 'dark' || (store.value === 'auto' && system.value === 'dark');
});
const themeName = computed(() => {
return store.value === 'auto' ? system.value : store.value;
});
const theme = computed(() => {
if (isDark.value) {
return darkTheme;
}
return { name: themeName.value };
});
const naiveLocale = computed(() => {
// 安全地比较locale的值,避免TypeScript类型错误
const currentLocale = locale.value.toString();
return currentLocale === 'zh-CN' || currentLocale === 'zh' ? zhCN : enUS;
});
const dateLocale = computed(() => {
// 安全地比较locale的值,避免TypeScript类型错误
const currentLocale = locale.value.toString();
return currentLocale === 'zh-CN' || currentLocale === 'zh' ? dateZhCN : null;
});
// 监听语言变化
watch(locale, newLocale => {
console.log('Language changed to:', newLocale);
+4 -4
View File
@@ -6,8 +6,8 @@
// biome-ignore lint: disable
export {}
declare global {
const useDialog: typeof import('naive-ui')['useDialog']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useNotification: typeof import('naive-ui')['useNotification']
const useDialog: typeof import('~/composables/ui')['useDialog']
const useLoadingBar: typeof import('~/composables/ui')['useLoadingBar']
const useMessage: typeof import('~/composables/ui')['useMessage']
const useNotification: typeof import('~/composables/ui')['useNotification']
}
+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>
+4
View File
@@ -0,0 +1,4 @@
export { useDialog } from '@/lib/ui/dialog'
export { useLoadingBar } from '@/lib/ui/loading-bar'
export { useMessage } from '@/lib/ui/message'
export { useNotification } from '@/lib/ui/notification'
+13
View File
@@ -0,0 +1,13 @@
# ShadCN Vue Migration Todo
- [x] Replace global layout shell (`layouts/default.vue`) to use `components/AppSidebar` built with ShadCN Sidebar primitives and update the layout structure similar to Sidebar07.
- [x] Build `components/AppSidebar.vue` and supporting subcomponents to render navigation, language/theme controls, and user menu with ShadCN UI widgets.
- [x] Replace Naive UI providers in `app.vue` with ShadCN-friendly structure, wiring toast/dialog replacements and preserving color mode handling.
- [ ] Convert shared form components and high-use Naive UI elements (buttons, inputs, tables, etc.) to their ShadCN equivalents, introducing reusable wrappers under `components/app-` when helpful.
- [x] Rebuild the users list tab (`components/users/tabs/user.vue`) with ShadCN table, checkbox, dialog, and button primitives.
- [x] Rebuild the user groups tab (`components/users/tabs/group.vue`) with ShadCN input, table, checkbox, and dialog primitives.
- [x] Rebuild the group members management view (`components/users/group/members.vue`) with ShadCN card, input, table, and multi-select patterns.
- [x] Rebuild the site replication management page (`pages/site-replication/index.vue`) and creation form (`components/site-replication/new-form.vue`) with ShadCN dialog, card, input, and button primitives.
- [x] Rebuild the event subscription management page (`pages/events/index.vue`) and creation form (`components/events/new-form.vue`) with ShadCN dialog, table, select, and checkbox primitives.
- [ ] Migrate feature-specific views to consume the new shared components, auditing for leftover Naive UI imports and styles.
- [ ] Remove Naive UI dependencies and configuration, ensuring ESLint/Vitest/build succeed.
+28 -9
View File
@@ -1,14 +1,33 @@
<template>
<n-space vertical>
<n-layout class="h-full">
<n-layout has-sider class="h-full">
<sidebar />
<div class="h-screen overflow-y-auto flex flex-col flex-1">
<div class="flex-1">
<div v-if="isAuthRoute" class="min-h-screen">
<slot />
</div>
<SidebarProvider v-else>
<div class="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset>
<div class="flex min-h-screen flex-1 flex-col">
<header class="sticky top-0 z-10 flex h-14 items-center gap-2 border-b bg-background px-4 shadow-sm md:hidden">
<SidebarTrigger />
<span class="text-sm font-semibold">{{ appConfig.name }}</span>
</header>
<div class="flex-1 overflow-y-auto bg-muted/20 p-4 md:p-8">
<slot />
</div>
</div>
</n-layout>
</n-layout>
</n-space>
</SidebarInset>
</div>
</SidebarProvider>
</template>
<script setup lang="ts">
import AppSidebar from '@/components/AppSidebar.vue';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { computed } from 'vue';
import type { AppConfig } from '~/types/app-config';
const route = useRoute();
const appConfig = useAppConfig() as unknown as AppConfig;
const isAuthRoute = computed(() => route.path.startsWith('/auth'));
</script>
+107
View File
@@ -0,0 +1,107 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, reactive, ref } from 'vue'
export type DialogTone = 'default' | 'destructive' | 'warning'
type DialogActionResult = void | boolean | Promise<void | boolean>
export interface DialogOptions {
title?: string
content?: string
positiveText?: string
negativeText?: string
onPositiveClick?: () => DialogActionResult
onNegativeClick?: () => DialogActionResult
}
export interface DialogInstance extends DialogOptions {
id: number
open: boolean
tone: DialogTone
}
export interface DialogHandle {
destroy: () => void
}
export interface DialogController {
dialogs: Ref<DialogInstance[]>
open: (options: DialogOptions, tone?: DialogTone) => DialogHandle
close: (id: number) => void
setOpen: (id: number, value: boolean) => void
}
export const dialogControllerKey: InjectionKey<DialogController> = Symbol('app-dialog')
let seed = 0
const removalDelay = 150
export const createDialogController = (): DialogController => {
const dialogs = ref<DialogInstance[]>([])
const remove = (id: number) => {
const index = dialogs.value.findIndex(item => item.id === id)
if (index !== -1) {
dialogs.value.splice(index, 1)
}
}
const close = (id: number) => {
const target = dialogs.value.find(item => item.id === id)
if (!target) return
target.open = false
setTimeout(() => remove(id), removalDelay)
}
const open = (options: DialogOptions, tone: DialogTone = 'default'): DialogHandle => {
const id = ++seed
const instance = reactive<DialogInstance>({
id,
open: true,
tone,
title: options.title,
content: options.content,
positiveText: options.positiveText,
negativeText: options.negativeText,
onPositiveClick: options.onPositiveClick,
onNegativeClick: options.onNegativeClick,
})
dialogs.value.push(instance)
return {
destroy: () => close(id),
}
}
const setOpen = (id: number, value: boolean) => {
if (value) return
close(id)
}
return {
dialogs,
open,
close,
setOpen,
}
}
export const useDialogController = () => {
const controller = inject(dialogControllerKey)
if (!controller) {
throw new Error('useDialog must be used within AppUiProvider')
}
return controller
}
export const useDialog = () => {
const controller = useDialogController()
return {
create: (options: DialogOptions) => controller.open(options, 'default'),
error: (options: DialogOptions) => controller.open(options, 'destructive'),
warning: (options: DialogOptions) => controller.open(options, 'warning'),
info: (options: DialogOptions) => controller.open(options, 'default'),
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface LoadingBarApi {
start: () => void
finish: () => void
error: () => void
}
const noop = () => {}
export const useLoadingBar = (): LoadingBarApi => ({
start: noop,
finish: noop,
error: noop,
})
+86
View File
@@ -0,0 +1,86 @@
import { inject } from 'vue'
import type { ExternalToast, ToastT } from 'vue-sonner'
import { toast } from 'vue-sonner'
export interface MessageOptions {
duration?: number
description?: string
}
export interface MessageHandle {
destroy: () => void
}
export interface MessageApi {
success: (content: string, options?: MessageOptions) => void
error: (content: string, options?: MessageOptions) => void
warning: (content: string, options?: MessageOptions) => void
info: (content: string, options?: MessageOptions) => void
loading: (content: string, options?: MessageOptions) => MessageHandle
destroyAll: () => void
}
export const messageInjectionKey = Symbol('app-message')
const mapOptions = (options?: MessageOptions): ExternalToast => {
if (!options) return {}
const mapped: ExternalToast = {}
if (options.duration !== undefined) {
mapped.duration = options.duration === 0 ? Number.POSITIVE_INFINITY : options.duration
}
if (options.description) {
mapped.description = options.description
}
return mapped
}
const show = (type: 'success' | 'error' | 'warning' | 'info', content: string, options?: MessageOptions) => {
const mapped = mapOptions(options)
switch (type) {
case 'success':
toast.success(content, mapped)
break
case 'error':
toast.error(content, mapped)
break
case 'warning':
toast.warning(content, mapped)
break
case 'info':
toast.info(content, mapped)
break
}
}
export const createMessageApi = (): MessageApi => {
const loading = (content: string, options?: MessageOptions): MessageHandle => {
const mapped = mapOptions(options)
const id: ToastT = toast.loading(content, mapped)
return {
destroy: () => toast.dismiss(id),
}
}
return {
success: (content, options) => show('success', content, options),
error: (content, options) => show('error', content, options),
warning: (content, options) => show('warning', content, options),
info: (content, options) => show('info', content, options),
loading,
destroyAll: () => toast.dismiss(),
}
}
export const useMessage = (): MessageApi => {
const api = inject<MessageApi>(messageInjectionKey)
if (!api) {
throw new Error('useMessage must be used within AppUiProvider')
}
return api
}
+69
View File
@@ -0,0 +1,69 @@
import type { ExternalToast, ToastT } from 'vue-sonner'
import { toast } from 'vue-sonner'
export interface NotificationOptions {
title?: string
description?: string
duration?: number
type?: 'default' | 'success' | 'error' | 'warning' | 'info'
}
export interface NotificationHandle {
destroy: () => void
}
const mapOptions = (options?: NotificationOptions): ExternalToast => {
if (!options) return {}
const mapped: ExternalToast = {}
if (options.duration !== undefined) {
mapped.duration = options.duration === 0 ? Number.POSITIVE_INFINITY : options.duration
}
if (options.description) {
mapped.description = options.description
}
return mapped
}
const show = (options: NotificationOptions): ToastT => {
const { title = '', type = 'default' } = options
const mapped = mapOptions(options)
switch (type) {
case 'success':
return toast.success(title, mapped)
case 'error':
return toast.error(title, mapped)
case 'warning':
return toast.warning(title, mapped)
case 'info':
return toast.info(title, mapped)
default:
return toast(title, mapped)
}
}
export const useNotification = () => {
return {
create: (options: NotificationOptions = {}) => {
const id = show(options)
return {
destroy: () => toast.dismiss(id),
}
},
success: (title: string, options?: NotificationOptions) => {
toast.success(title, mapOptions(options))
},
error: (title: string, options?: NotificationOptions) => {
toast.error(title, mapOptions(options))
},
warning: (title: string, options?: NotificationOptions) => {
toast.warning(title, mapOptions(options))
},
info: (title: string, options?: NotificationOptions) => {
toast.info(title, mapOptions(options))
},
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ export default defineNuxtConfig({
AutoImport({
imports: [
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
'~/composables/ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
},
],
}),
+159 -157
View File
@@ -1,43 +1,133 @@
<template>
<div>
<div class="space-y-4">
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Events') }}</h1>
</template>
</page-header>
<page-content class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div style="width: 300px">
<n-form-item :label="t('Bucket')" path="" class="flex-auto" label-placement="left">
<n-select filterable v-model:value="bucketName" :placeholder="t('Please select bucket')" :options="bucketList" />
</n-form-item>
<page-content class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="w-full max-w-sm space-y-2">
<Label for="bucket-select">{{ t('Bucket') }}</Label>
<Select id="bucket-select" v-model="bucketName" :disabled="!bucketList.length">
<SelectTrigger>
<SelectValue :placeholder="t('Please select bucket')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="bucket in bucketList"
:key="bucket.value"
:value="bucket.value"
>
{{ bucket.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-4">
<n-button @click="() => handleNew()">
<Icon name="ri:add-line" class="mr-2" />
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" @click="handleNew">
<Icon class="size-4" name="ri:add-line" />
<span>{{ t('Add Event Subscription') }}</span>
</n-button>
<n-button @click="refresh">
<Icon name="ri:refresh-line" class="mr-2" />
</Button>
<Button type="button" variant="secondary" @click="handleRefresh" :disabled="loading">
<Icon class="size-4" name="ri:refresh-line" />
<span>{{ t('Refresh') }}</span>
</n-button>
</Button>
</div>
</div>
<n-data-table v-if="pageData.length > 0" class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="pageData" :pagination="false"
:bordered="false" :loading="loading" />
<n-card v-else class="flex flex-center" style="height: 400px">
<n-empty :description="t('No Data')"></n-empty>
</n-card>
<events-new-form ref="newRef" :bucketName="bucketName" @success="refresh"></events-new-form>
<div class="relative">
<div
v-if="loading"
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg border bg-background/70 backdrop-blur-sm"
>
<Spinner class="size-6 text-muted-foreground" />
</div>
<div v-if="pageData.length" class="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-28">{{ t('Type') }}</TableHead>
<TableHead class="min-w-[180px]">{{ t('ARN') }}</TableHead>
<TableHead class="w-52">{{ t('Events') }}</TableHead>
<TableHead class="w-36">{{ t('Prefix') }}</TableHead>
<TableHead class="w-36">{{ t('Suffix') }}</TableHead>
<TableHead class="w-24 text-center">{{ t('Actions') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in pageData" :key="row.id">
<TableCell>
<Badge :class="typeBadgeClasses[row.type]">
{{ row.type }}
</Badge>
</TableCell>
<TableCell class="font-medium">
<span class="line-clamp-2 break-all">{{ row.arn }}</span>
</TableCell>
<TableCell>
<div class="flex flex-wrap gap-1">
<Badge
v-for="event in getDisplayEvents(row.events)"
:key="event"
variant="secondary"
>
{{ event }}
</Badge>
</div>
</TableCell>
<TableCell>{{ row.prefix || '-' }}</TableCell>
<TableCell>{{ row.suffix || '-' }}</TableCell>
<TableCell>
<div class="flex justify-center">
<Button
type="button"
size="sm"
variant="secondary"
class="gap-2"
@click="event => handleRowDelete(row, event)"
>
<Icon class="size-4" name="ri:delete-bin-7-line" />
{{ t('Delete') }}
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Card v-else class="relative">
<CardContent class="py-16">
<Empty class="mx-auto max-w-sm text-center">
<EmptyHeader>
<EmptyTitle>{{ t('No Data') }}</EmptyTitle>
<EmptyDescription>{{ t('Add Event Subscription to get started') }}</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</div>
<events-new-form ref="newRef" :bucketName="bucketName" @success="refresh" />
</page-content>
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import { Icon } from '#components'
import { NButton, NSpace, NTag, type DataTableColumns } from 'naive-ui'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -55,123 +145,28 @@ interface NotificationItem {
filterRules?: Array<{ Name: string; Value: string }>
}
// 事件映射:将 S3 标准事件映射回简化的显示名称
const eventDisplayMapping: Record<string, string> = {
// PUT 相关事件
's3:0bjectCreated:*': 'PUT',
// GET 相关事件
's3:0bjectAccessed:*': 'GET',
// DELETE 相关事件
's3:0bjectRemoved:*': 'DELETE',
// REPLICA 相关事件
's3:Replication:*': 'REPLICA',
// RESTORE 相关事件
's3:ObjectRestore:*': 'RESTORE',
's3:0bjectTransition:*': 'RESTORE',
// SCANNER 相关事件
's3:Scanner:ManyVersions': 'SCANNER',
's3:Scanner:BigPrefix': 'SCANNER',
}
// 将 S3 事件转换为显示名称
const getEventDisplayName = (s3Event: string): string => {
return eventDisplayMapping[s3Event] || s3Event
const getDisplayEvents = (events: string[]) => {
return [...new Set(events.map(event => eventDisplayMapping[event] || event))]
}
const columns: DataTableColumns<NotificationItem> = [
{
title: t('Type'),
key: 'type',
width: 100,
render: (row: NotificationItem) => {
const typeColors = {
Lambda: 'warning',
SQS: 'primary',
SNS: 'success',
Topic: 'info',
}
return h(
NTag,
{
type: typeColors[row.type] as any,
size: 'small',
},
{ default: () => row.type }
)
},
},
{
title: t('ARN'),
key: 'arn',
ellipsis: {
tooltip: true,
},
},
{
title: t('Events'),
key: 'events',
width: 200,
render: (row: NotificationItem) => {
// 将 S3 事件转换为显示名称并去重
const displayEvents = [...new Set(row.events.map(getEventDisplayName))]
const typeBadgeClasses: Record<NotificationItem['type'], string> = {
Lambda: 'bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-100',
SQS: 'bg-sky-100 text-sky-900 dark:bg-sky-900/40 dark:text-sky-100',
SNS: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-100',
Topic: 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900/40 dark:text-indigo-100',
}
return h(
'div',
{ class: 'flex flex-wrap gap-1' },
displayEvents.map(event => h('n-tag', { size: 'tiny', type: 'info' }, { default: () => event }))
)
},
},
{
title: t('Prefix'),
key: 'prefix',
width: 120,
render: (row: NotificationItem) => row.prefix || '-',
},
{
title: t('Suffix'),
key: 'suffix',
width: 120,
render: (row: NotificationItem) => row.suffix || '-',
},
{
title: t('Actions'),
key: 'actions',
align: 'center',
width: 100,
render: (row: NotificationItem) => {
return h(
NSpace,
{
justify: 'center',
},
{
default: () => [
h(
NButton,
{
size: 'small',
secondary: true,
onClick: e => handleRowDelete(row, e),
},
{
default: () => t('Delete'),
icon: () => h(Icon, { name: 'ri:delete-bin-7-line' }),
}
),
],
}
)
},
},
]
// 获取桶列表
const { data } = await useAsyncData(
'buckets',
async () => {
@@ -182,40 +177,45 @@ const { data } = await useAsyncData(
}) || []
)
},
{ default: () => [] }
{ default: () => [] },
)
const bucketList = computed(() => {
return data.value.map(bucket => ({
return data.value.map((bucket: any) => ({
label: bucket.Name,
value: bucket.Name,
}))
})
const bucketName = ref<string>(bucketList.value.length > 0 ? (bucketList.value[0]?.value ?? '') : '')
const loading = ref<boolean>(false)
const bucketName = ref<string>(bucketList.value.length > 0 ? bucketList.value[0]?.value ?? '' : '')
const loading = ref(false)
const pageData = ref<NotificationItem[]>([])
watch(
() => bucketName.value,
async newVal => {
if (!newVal) return
loading.value = true
try {
refresh()
} catch (error) {
if (!newVal) {
pageData.value = []
} finally {
loading.value = false
return
}
await refresh()
},
{ immediate: true }
{ immediate: true },
)
const handleRowDelete = async (row: NotificationItem, e: Event) => {
e.stopPropagation()
watch(
() => bucketList.value,
newBuckets => {
if (!bucketName.value && newBuckets.length) {
bucketName.value = newBuckets[0].value
}
},
)
const handleRowDelete = async (row: NotificationItem, event: Event) => {
event.stopPropagation()
// 显示确认对话框
const confirmed = await new Promise<boolean>(resolve => {
dialog.warning({
title: t('Confirm Delete'),
@@ -232,29 +232,29 @@ const handleRowDelete = async (row: NotificationItem, e: Event) => {
try {
loading.value = true
// 获取当前的通知配置
const currentResponse = await listBucketNotifications(bucketName.value)
const currentNotifications = currentResponse || {}
// 根据类型从对应数组中移除配置
let updatedConfigurations: any[] = []
if (row.type === 'Lambda' && currentNotifications.LambdaFunctionConfigurations) {
updatedConfigurations = currentNotifications.LambdaFunctionConfigurations.filter(
(config: any) => config.Id !== row.id
(config: any) => config.Id !== row.id,
)
} else if (row.type === 'SQS' && currentNotifications.QueueConfigurations) {
updatedConfigurations = currentNotifications.QueueConfigurations.filter((config: any) => config.Id !== row.id)
updatedConfigurations = currentNotifications.QueueConfigurations.filter(
(config: any) => config.Id !== row.id,
)
} else if (row.type === 'SNS' && currentNotifications.TopicConfigurations) {
updatedConfigurations = currentNotifications.TopicConfigurations.filter((config: any) => config.Id !== row.id)
updatedConfigurations = currentNotifications.TopicConfigurations.filter(
(config: any) => config.Id !== row.id,
)
}
// 构建新的通知配置
const newNotificationConfig = {
...currentNotifications,
}
// 更新对应类型的配置数组
if (row.type === 'Lambda') {
newNotificationConfig.LambdaFunctionConfigurations = updatedConfigurations
} else if (row.type === 'SQS') {
@@ -263,30 +263,33 @@ const handleRowDelete = async (row: NotificationItem, e: Event) => {
newNotificationConfig.TopicConfigurations = updatedConfigurations
}
// 提交更新后的配置
await putBucketNotifications(bucketName.value, newNotificationConfig)
// 显示成功消息
message.success(t('Delete Success'))
// 刷新列表
await refresh()
} catch (error: any) {
console.error('删除通知配置失败:', error)
message.error(t('Delete Failed') + ': ' + (error.message || error))
message.error(`${t('Delete Failed')}: ${error.message || error}`)
} finally {
loading.value = false
}
}
const newRef = ref()
const handleNew = () => {
newRef.value.open()
newRef.value?.open()
}
const handleRefresh = async () => {
await refresh()
}
const refresh = async () => {
loading.value = true
if (!bucketName.value) {
pageData.value = []
loading.value = false
return
}
@@ -294,7 +297,6 @@ const refresh = async () => {
const response = await listBucketNotifications(bucketName.value)
const notifications: NotificationItem[] = []
// 处理 Lambda 函数配置
if (response.LambdaFunctionConfigurations) {
response.LambdaFunctionConfigurations.forEach((config: any) => {
const prefix = config.Filter?.Key?.FilterRules?.find((rule: any) => rule.Name === 'Prefix')?.Value
@@ -312,7 +314,6 @@ const refresh = async () => {
})
}
// 处理 SQS 队列配置
if (response.QueueConfigurations) {
response.QueueConfigurations.forEach((config: any) => {
const prefix = config.Filter?.Key?.FilterRules?.find((rule: any) => rule.Name === 'Prefix')?.Value
@@ -330,7 +331,6 @@ const refresh = async () => {
})
}
// 处理 SNS 主题配置
if (response.TopicConfigurations) {
response.TopicConfigurations.forEach((config: any) => {
const prefix = config.Filter?.Key?.FilterRules?.find((rule: any) => rule.Name === 'Prefix')?.Value
@@ -352,6 +352,8 @@ const refresh = async () => {
} catch (error) {
console.error('获取通知配置失败:', error)
pageData.value = []
} finally {
loading.value = false
}
}
</script>
+21 -16
View File
@@ -6,30 +6,35 @@
</template>
</page-header>
<page-content class="flex flex-col gap-4">
<n-flex justify="end">
<n-button @click="() => openForm()">
<Icon name="ri:add-line" class="mr-2" />
<div class="flex justify-end">
<Button type="button" variant="secondary" class="inline-flex items-center gap-2" @click="openForm">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Site') }}</span>
</n-button>
</n-flex>
</Button>
</div>
<Card class="min-h-[400px]">
<CardContent class="flex h-full items-center justify-center">
<p class="text-sm text-muted-foreground">{{ t('No Data') }}</p>
</CardContent>
</Card>
<n-card class="flex flex-center" style="height: 400px">
<n-empty :description="t('No Data')"></n-empty>
</n-card>
<!-- <n-data-table class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="pageData" :pagination="false" :bordered="false" /> -->
<site-replication-new-form ref="addFormRef"></site-replication-new-form>
</page-content>
</div>
</template>
<script lang="ts" setup>
import { Icon } from '#components';
import { NButton } from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const addFormRef = ref<{ open: () => void } | null>(null)
const { t } = useI18n();
const addFormRef = ref();
const openForm = () => {
addFormRef.value.open();
};
addFormRef.value?.open()
}
</script>
-20
View File
@@ -1,20 +0,0 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export const useSidebarStore = defineStore('sidebar', () => {
const isCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value;
};
const setSidebarState = (collapsed: boolean) => {
isCollapsed.value = collapsed;
};
watch(isCollapsed, newValue => {
localStorage.setItem('sidebarCollapsed', newValue.toString());
});
return { isCollapsed, toggleSidebar, setSidebarState };
});