mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
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:
@@ -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);
|
||||
|
||||
Vendored
+4
-4
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -115,7 +115,7 @@ export default defineNuxtConfig({
|
||||
AutoImport({
|
||||
imports: [
|
||||
{
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
|
||||
'~/composables/ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
+159
-157
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user