mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
fix: tables & forms
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
- Vue files use `<script setup>` with TypeScript; prefer composables for shared logic.
|
||||
- Components use StudlyCase filenames (e.g. `BucketSelector.vue`) but reference via kebab-case in templates.
|
||||
- Override shadcn primitives **outside** `components/ui/`; never edit files in that directory directly.
|
||||
- Render tabular data with the shared `DataTable` + `useDataTable` utilities unless a specific requirement makes them unsuitable.
|
||||
|
||||
## Testing Guidelines
|
||||
- Vitest is the primary framework; add new suites under `tests/`.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -101,22 +101,12 @@ async function submitForm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model="modalVisible"
|
||||
:title="t('Change current account password')"
|
||||
size="md"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="modalVisible" :title="t('Change current account password')" size="md" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel for="password-current">{{ t('Current Password') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-current"
|
||||
v-model="formModel.current_secret_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input id="password-current" v-model="formModel.current_secret_key" type="password" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.current_secret_key" class="text-destructive">
|
||||
{{ errors.current_secret_key }}
|
||||
@@ -126,12 +116,7 @@ async function submitForm() {
|
||||
<Field>
|
||||
<FieldLabel for="password-new">{{ t('New Password') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new"
|
||||
v-model="formModel.new_secret_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input id="password-new" v-model="formModel.new_secret_key" type="password" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.new_secret_key" class="text-destructive">
|
||||
{{ errors.new_secret_key }}
|
||||
@@ -141,13 +126,7 @@ async function submitForm() {
|
||||
<Field>
|
||||
<FieldLabel for="password-new-confirm">{{ t('Confirm New Password') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="password-new-confirm"
|
||||
v-model="formModel.re_new_secret_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:disabled="!formModel.new_secret_key"
|
||||
/>
|
||||
<Input id="password-new-confirm" v-model="formModel.re_new_secret_key" type="password" autocomplete="off" :disabled="!formModel.new_secret_key" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.re_new_secret_key" class="text-destructive">
|
||||
{{ errors.re_new_secret_key }}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
import DateTimePicker from '@/components/datetime-picker.vue'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
@@ -93,12 +93,7 @@ async function submitForm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visible"
|
||||
:title="t('Edit Key')"
|
||||
size="lg"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="visible" :title="t('Edit Key')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
|
||||
@@ -131,11 +126,11 @@ async function submitForm() {
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Description') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea v-model="formModel.description" :rows="3" />
|
||||
<Textarea v-model="formModel.description" :rows="2" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" class="items-center rounded-md border p-3">
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Status') }}</FieldLabel>
|
||||
<FieldContent class="flex justify-end">
|
||||
<Switch v-model:checked="statusBoolean" />
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="modalVisible"
|
||||
:title="t('Create Key')"
|
||||
size="lg"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="modalVisible" :title="t('Create Key')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel for="create-access-key">{{ t('Access Key') }}</FieldLabel>
|
||||
@@ -29,12 +24,7 @@
|
||||
<Field>
|
||||
<FieldLabel for="create-expiry">{{ t('Expiry') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-expiry"
|
||||
v-model="formModel.expiry"
|
||||
:min="minExpiry"
|
||||
:placeholder="t('Please select expiry date')"
|
||||
/>
|
||||
<DateTimePicker id="create-expiry" v-model="formModel.expiry" :min="minExpiry" :placeholder="t('Please select expiry date')" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.expiry" class="text-destructive">
|
||||
{{ errors.expiry }}
|
||||
@@ -91,17 +81,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
import DateTimePicker from '@/components/datetime-picker.vue'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
import { makeRandomString } from '~/utils/functions'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -99,7 +99,7 @@ const getLabel = (item: NavItem) => t(item.label)
|
||||
<Collapsible v-if="hasChildren(item)" as-child :default-open="isRouteActive(item)" class="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :is-active="isRouteActive(item)" :tooltip="getLabel(item)">
|
||||
<SidebarMenuButton :is-active="isRouteActive(item)" :tooltip="getLabel(item)" class="gap-3">
|
||||
<Icon v-if="item.icon" :name="item.icon" class="size-4 shrink-0" />
|
||||
<span class="flex-1 truncate">{{ getLabel(item) }}</span>
|
||||
<Icon name="ri:arrow-right-s-line" class="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Selector, { type SelectOption } from '@/components/selector.vue'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import Selector, { type SelectOption } from '~/components/selector.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
@@ -57,14 +57,8 @@ const controlWrapperClasses = computed(() =>
|
||||
{{ props.label }}
|
||||
</Label>
|
||||
<div :class="controlWrapperClasses">
|
||||
<Selector
|
||||
v-model="modelValue"
|
||||
:options="props.options"
|
||||
:placeholder="props.placeholder"
|
||||
:disabled="props.disabled || props.loading"
|
||||
:empty-message="props.emptyMessage"
|
||||
:class="cn('min-w-[200px]', props.selectorClass)"
|
||||
/>
|
||||
<Selector v-model="modelValue" :options="props.options" :placeholder="props.placeholder" :disabled="props.disabled || props.loading" :empty-message="props.emptyMessage"
|
||||
:class="cn('min-w-[200px]', props.selectorClass)" />
|
||||
<p v-if="props.description" class="text-xs text-muted-foreground">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
|
||||
@@ -256,9 +256,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Drawer from '@/components/drawer.vue'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import Selector from '@/components/selector.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemHeader, ItemTitle } from '@/components/ui/item'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
@@ -266,6 +263,9 @@ import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Drawer from '~/components/drawer.vue'
|
||||
import Modal from '~/components/modal.vue'
|
||||
import Selector from '~/components/selector.vue'
|
||||
import type { BucketPolicyType } from '~/utils/bucket-policy'
|
||||
import { getBucketPolicy as detectBucketPolicy, setBucketPolicy } from '~/utils/bucket-policy'
|
||||
|
||||
|
||||
@@ -38,11 +38,7 @@
|
||||
<FieldLabel>{{ t('Retention Mode') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<RadioGroup v-model="retentionMode" class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="option in retentionModeOptions"
|
||||
:key="option.value"
|
||||
class="flex items-start gap-3 rounded-md border border-border/50 p-3"
|
||||
>
|
||||
<label v-for="option in retentionModeOptions" :key="option.value" class="flex items-start gap-3 rounded-md border border-border/50 p-3">
|
||||
<RadioGroupItem :value="option.value" class="mt-0.5" />
|
||||
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||
</label>
|
||||
@@ -56,11 +52,7 @@
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Input v-model="retentionPeriod" type="number" class="sm:w-32" />
|
||||
<RadioGroup v-model="retentionUnit" class="grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="option in retentionUnitOptions"
|
||||
:key="option.value"
|
||||
class="flex items-start gap-3 rounded-md border border-border/50 p-3"
|
||||
>
|
||||
<label v-for="option in retentionUnitOptions" :key="option.value" class="flex items-start gap-3 rounded-md border border-border/50 p-3">
|
||||
<RadioGroupItem :value="option.value" class="mt-0.5" />
|
||||
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||
</label>
|
||||
@@ -89,11 +81,11 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts" generic="TData">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
import Selector from '@/components/selector.vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
import { computed } from 'vue'
|
||||
import Selector from '~/components/selector.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -42,12 +42,8 @@ const handlePageSizeChange = (value: number | string | boolean | null) => {
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Rows per page
|
||||
</span>
|
||||
<Selector
|
||||
:options="pageSizeOptions.map(option => ({ label: String(option), value: option }))"
|
||||
:model-value="pagination.pageSize"
|
||||
class="w-24"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
/>
|
||||
<Selector :options="pageSizeOptions.map(option => ({ label: String(option), value: option }))" :model-value="pagination.pageSize" class="w-24"
|
||||
@update:model-value="handlePageSizeChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -64,12 +60,7 @@ const handlePageSizeChange = (value: number | string | boolean | null) => {
|
||||
<Button variant="outline" size="sm" :disabled="!canNext" @click="props.table.nextPage()">
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!canNext"
|
||||
@click="props.table.setPageIndex(Math.max(pageCount - 1, 0))"
|
||||
>
|
||||
<Button variant="outline" size="sm" :disabled="!canNext" @click="props.table.setPageIndex(Math.max(pageCount - 1, 0))">
|
||||
Last
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, reactive, ref, toValue, watch, type Ref } from 'vue'
|
||||
import { computed, reactive, ref, toValue, type Ref } from 'vue'
|
||||
|
||||
export interface UseDataTableOptions<TData extends RowData> {
|
||||
data: MaybeRefOrGetter<TData[]>
|
||||
@@ -61,7 +61,7 @@ export function useDataTable<TData extends RowData>(
|
||||
const columnsRef = computed(() => toValue(options.columns))
|
||||
|
||||
const table = useVueTable({
|
||||
data: dataRef.value,
|
||||
data: dataRef,
|
||||
columns: columnsRef.value,
|
||||
state: reactive({
|
||||
get sorting() {
|
||||
@@ -99,20 +99,6 @@ export function useDataTable<TData extends RowData>(
|
||||
getPaginationRowModel: options.manualPagination ? undefined : getPaginationRowModel(),
|
||||
})
|
||||
|
||||
watch(dataRef, value => {
|
||||
table.setOptions(prev => ({
|
||||
...prev,
|
||||
data: value,
|
||||
}))
|
||||
})
|
||||
|
||||
watch(columnsRef, value => {
|
||||
table.setOptions(prev => ({
|
||||
...prev,
|
||||
columns: value,
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
table,
|
||||
sorting,
|
||||
|
||||
@@ -64,12 +64,12 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { useEventTarget } from '#imports'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MqttIcon from '~/assets/svg/mqtt.svg'
|
||||
import WebhooksIcon from '~/assets/svg/webhooks.svg'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { updateEventTarget } = useEventTarget()
|
||||
+22
-25
@@ -1,45 +1,42 @@
|
||||
<script setup>
|
||||
import JsonEditorVue from 'json-editor-vue';
|
||||
import 'vanilla-jsoneditor/themes/jse-theme-dark.css';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import JsonEditorVue from 'json-editor-vue'
|
||||
import 'vanilla-jsoneditor/themes/jse-theme-dark.css'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// import { createAjvValidator } from 'svelte-jsoneditor'
|
||||
|
||||
// const validator = createAjvValidator({ schema, schemaDefinitions })
|
||||
const { t } = useI18n();
|
||||
const attrs = useAttrs();
|
||||
const model = defineModel();
|
||||
// const value = ref(model.value)
|
||||
// 监听model变化
|
||||
const { t } = useI18n()
|
||||
const attrs = useAttrs()
|
||||
const model = defineModel()
|
||||
|
||||
watch(
|
||||
model,
|
||||
newValue => {
|
||||
model.value = JSON.stringify(JSON.parse(newValue), null, 2);
|
||||
// value.value = JSON.stringify(JSON.parse(newValue), null, 2)
|
||||
if (typeof newValue !== 'string') return
|
||||
const trimmed = newValue.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(trimmed), null, 2)
|
||||
if (formatted !== newValue) {
|
||||
model.value = formatted
|
||||
}
|
||||
} catch {
|
||||
// Swallow parse errors so the editor stays usable while typing
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- :validator="validator" 验证器 -->
|
||||
<div class="w-full">
|
||||
<JsonEditorVue
|
||||
v-bind="attrs"
|
||||
mode="text"
|
||||
v-model="model"
|
||||
:mainMenuBar="false"
|
||||
selection="TextSelection"
|
||||
class="editor w-full jse-theme-dark"
|
||||
:title="t('JSON Editor')"
|
||||
/>
|
||||
<JsonEditorVue v-bind="attrs" mode="text" v-model="model" :mainMenuBar="false" selection="TextSelection" class="editor w-full jse-theme-dark" :title="t('JSON Editor')" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
:deep(.jse-main) {
|
||||
min-height: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
|
||||
@@ -163,13 +163,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import Selector from '@/components/selector.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
import Selector from '~/components/selector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -127,14 +127,8 @@
|
||||
</Modal>
|
||||
|
||||
<object-preview-modal v-model:show="showPreview" :object="previewObject ?? object" />
|
||||
<ObjectVersions
|
||||
:bucket-name="bucketName"
|
||||
:object-key="object?.Key || ''"
|
||||
:visible="showVersions"
|
||||
@close="handleVersionsClose"
|
||||
@preview="handlePreviewVersion"
|
||||
@refresh-parent="handleVersionsRefresh"
|
||||
/>
|
||||
<ObjectVersions :bucket-name="bucketName" :object-key="object?.Key || ''" :visible="showVersions" @close="handleVersionsClose" @preview="handlePreviewVersion"
|
||||
@refresh-parent="handleVersionsRefresh" />
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
@@ -146,15 +140,15 @@ import { joinRelativeURL } from 'ufo'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Drawer from '@/components/drawer.vue'
|
||||
import ObjectVersions from '@/components/object/versions.vue'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from '@/components/ui/item'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import Drawer from '~/components/drawer.vue'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<template #actions>
|
||||
<object-upload-stats />
|
||||
<object-delete-stats />
|
||||
<Button variant="secondary" @click="() => handleNewObject(true)">
|
||||
<Button variant="outline" @click="() => handleNewObject(true)">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('New Folder') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" @click="() => (uploadPickerVisible = true)">
|
||||
<Button variant="outline" @click="() => (uploadPickerVisible = true)">
|
||||
<Icon name="ri:file-add-line" class="size-4" />
|
||||
<span>{{ t('Upload File') }}/{{ t('Folder') }}</span>
|
||||
</Button>
|
||||
<Button variant="destructive" :disabled="!checkedKeys.length" v-show="checkedKeys.length" @click="handleBatchDelete">
|
||||
<Button variant="outline" class="text-destructive border-destructive" :disabled="!checkedKeys.length" v-show="checkedKeys.length" @click="handleBatchDelete">
|
||||
<Icon name="ri:delete-bin-5-line" class="size-4" />
|
||||
<span>{{ t('Delete Selected') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
<AlertDescription>{{ t('Overwrite Warning') }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Input
|
||||
v-model="objectKey"
|
||||
:placeholder="t('Name Placeholder', { type: displayType })"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input v-model="objectKey" :placeholder="t('Name Placeholder', { type: displayType })" autocomplete="off" />
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="closeModal">{{ t('Close') }}</Button>
|
||||
@@ -22,14 +18,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { joinRelativeURL } from 'ufo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Modal v-model="visibleProxy" :title="t('Preview')" size="xl" :close-on-backdrop="false">
|
||||
<Modal v-model="visibleProxy" :title="t('Preview')" size="xl" :close-on-backdrop="false" class="z-1000">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="min-h-[300px] rounded-md border p-4 flex flex-col">
|
||||
<Spinner v-if="loading" class="mx-auto size-8 text-muted-foreground" />
|
||||
@@ -27,10 +27,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -71,13 +71,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import Drawer from '@/components/drawer.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import Progress from '@/components/ui/progress/Progress.vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Drawer from '~/components/drawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: any[]
|
||||
|
||||
@@ -144,11 +144,11 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import Progress from '@/components/ui/progress/Progress.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Modal v-model="visibleProxy" :title="t('Object Versions')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 z-20">
|
||||
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Versions')" />
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" @click="closeModal">{{ t('Close') }}</Button>
|
||||
@@ -14,13 +14,13 @@ import { Button } from '@/components/ui/button'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
bucketName: string
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<object-preview-modal :show="showPreview" :object="object" @update:show="showPreview = $event" />
|
||||
|
||||
<Modal v-model="showTagView" :title="t('Set Tags')" size="lg">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -92,23 +94,21 @@
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<object-preview-modal :show="showPreview" :object="object" @update:show="showPreview = $event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Item, ItemContent } from '@/components/ui/item'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Item, ItemContent } from '@/components/ui/item'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
bucketName: string
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { $api } = useNuxtApp()
|
||||
@@ -114,12 +114,7 @@ const submitForm = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model="modalVisible"
|
||||
:title="t('Policy Original')"
|
||||
size="lg"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="modalVisible" :title="t('Policy Original')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel for="policy-name">{{ t('Policy Name') }}</FieldLabel>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visible"
|
||||
:title="t('Add Replication Rule') + ` (${t('Bucket')}: ${bucketName})`"
|
||||
size="xl"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="visible" :title="t('Add Replication Rule') + ` (${t('Bucket')}: ${bucketName})`" size="xl" :close-on-backdrop="false">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
@@ -74,21 +69,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="formData.tags.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(tag, index) in formData.tags"
|
||||
:key="index"
|
||||
class="grid gap-2 rounded-md border p-3 md:grid-cols-2 md:items-center md:gap-4"
|
||||
>
|
||||
<div v-for="(tag, index) in formData.tags" :key="index" class="grid gap-2 rounded-md border p-3 md:grid-cols-2 md:items-center md:gap-4">
|
||||
<Input v-model="tag.key" :placeholder="t('Tag Name')" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Input v-model="tag.value" :placeholder="t('Tag Value')" class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive"
|
||||
:disabled="formData.tags.length === 1"
|
||||
@click="removeTag(index)"
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="text-destructive" :disabled="formData.tags.length === 1" @click="removeTag(index)">
|
||||
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -126,12 +111,7 @@
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Health Check Interval (seconds)') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
v-model="formData.timecheck"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-32"
|
||||
/>
|
||||
<Input v-model="formData.timecheck" type="number" min="1" class="w-32" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -157,17 +137,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import Selector from '@/components/selector.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getBytes } from '@/utils/functions'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getBytes } from '@/utils/functions'
|
||||
import Modal from '~/components/modal.vue'
|
||||
import Selector from '~/components/selector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visibleProxy"
|
||||
:title="t('Update Key') + ':' + nameProxy"
|
||||
size="md"
|
||||
:close-on-backdrop="false"
|
||||
>
|
||||
<Modal v-model="visibleProxy" :title="t('Update Key') + ':' + nameProxy" size="md" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
|
||||
@@ -31,13 +26,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -102,13 +102,13 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import RustfsIcon from '~/assets/logo.svg'
|
||||
import AWSIcon from '~/assets/svg/aws.svg'
|
||||
import MinioIcon from '~/assets/svg/minio.svg'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const usetier = useTiers()
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const visible = ref(false)
|
||||
@@ -6,14 +6,16 @@
|
||||
<div class="w-full sm:max-w-xs">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search User')" clearable class="w-full" />
|
||||
</div>
|
||||
<Button type="button" variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Button type="button" variant="outline" 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>
|
||||
<Field>
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Select user group members') }}</FieldLabel>
|
||||
<FieldContent class="space-y-2">
|
||||
<Popover v-model:open="memberSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
@@ -59,9 +61,11 @@
|
||||
<div v-if="members.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in members" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:self-start">
|
||||
<Button type="button" variant="secondary" @click="changeMembers">{{ t('Submit') }}</Button>
|
||||
<Button type="button" variant="outline" @click="changeMembers">{{ t('Submit') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -95,7 +99,7 @@ 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 { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
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'
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -106,59 +105,49 @@ const submitForm = async () => {
|
||||
<template>
|
||||
<Modal v-model="modalVisible" :title="t('Add group members')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ t('Name') }}</Label>
|
||||
<Input v-model="formModel.group" autocomplete="off" />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.group" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">{{ t('Users') }}</Label>
|
||||
<Popover v-model:open="memberSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 w-full justify-between gap-2"
|
||||
:aria-label="t('Users')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{
|
||||
selectedMemberLabels.length ? selectedMemberLabels.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="formModel.members.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="formModel.members.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in formModel.members" :key="value" variant="secondary">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Users') }}</FieldLabel>
|
||||
<FieldContent class="space-y-2">
|
||||
<Popover v-model:open="memberSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="outline" class="min-h-10 w-full justify-between gap-2" :aria-label="t('Users')">
|
||||
<span class="truncate">
|
||||
{{
|
||||
selectedMemberLabels.length ? selectedMemberLabels.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="formModel.members.includes(option.value) ? 'opacity-100' : 'opacity-0'" />
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="formModel.members.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in formModel.members" :key="value" variant="secondary">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -4,62 +4,64 @@
|
||||
<div class="w-full sm:max-w-xs">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Policy')" clearable class="w-full" />
|
||||
</div>
|
||||
<Button variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Button variant="outline" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Edit Policy') }}
|
||||
</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 policies') }}</Label>
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Select user group policies')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in policies"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => togglePolicy(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="selectedPolicies.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedPolicies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedPolicies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field class="flex w-full flex-col gap-2">
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Select user group policies') }}</FieldLabel>
|
||||
<FieldContent class="space-y-2">
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Select user group policies')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in policies"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => togglePolicy(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="selectedPolicies.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedPolicies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedPolicies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div class="flex items-center gap-2 sm:self-start">
|
||||
<Button variant="ghost" @click="cancelEditing">
|
||||
{{ t('Cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" @click="changePolicies">
|
||||
<Button variant="outline" @click="changePolicies">
|
||||
{{ t('Submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,7 +94,7 @@ import { Icon } from '#components'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
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'
|
||||
+5
-11
@@ -5,7 +5,7 @@
|
||||
<div class="w-full sm:max-w-xs">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Policy')" clearable class="w-full" />
|
||||
</div>
|
||||
<Button variant="secondary" :disabled="!checkedKeys.length || submitting" @click="changePolicies">
|
||||
<Button variant="outline" :disabled="!checkedKeys.length || submitting" @click="changePolicies">
|
||||
{{ t('Submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -14,11 +14,8 @@
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-12">
|
||||
<Checkbox
|
||||
:checked="allVisibleSelected"
|
||||
:indeterminate="checkedKeys.length > 0 && !allVisibleSelected"
|
||||
@update:checked="(value: boolean | 'indeterminate') => toggleSelectAll(value === true)"
|
||||
/>
|
||||
<Checkbox :checked="allVisibleSelected" :indeterminate="checkedKeys.length > 0 && !allVisibleSelected"
|
||||
@update:checked="(value: boolean | 'indeterminate') => toggleSelectAll(value === true)" />
|
||||
</TableHead>
|
||||
<TableHead>{{ t('Name') }}</TableHead>
|
||||
</TableRow>
|
||||
@@ -26,10 +23,7 @@
|
||||
<TableBody v-if="filteredPolicies.length">
|
||||
<TableRow v-for="policy in filteredPolicies" :key="policy.name">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
:checked="isSelected(policy.name)"
|
||||
@update:checked="(value: boolean | 'indeterminate') => toggleSelection(policy.name, value === true)"
|
||||
/>
|
||||
<Checkbox :checked="isSelected(policy.name)" @update:checked="(value: boolean | 'indeterminate') => toggleSelection(policy.name, value === true)" />
|
||||
</TableCell>
|
||||
<TableCell class="font-medium">{{ policy.name }}</TableCell>
|
||||
</TableRow>
|
||||
@@ -49,11 +43,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { listPolicies, setUserOrGroupPolicy } = usePolicies()
|
||||
const { t } = useI18n()
|
||||
@@ -1,58 +1,70 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Access Key') }}</Label>
|
||||
<Input v-model="formModel.accessKey" disabled />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.accessKey" disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Expiration') }}</Label>
|
||||
<DateTimePicker
|
||||
v-model="formModel.expiry"
|
||||
:min="minExpiry"
|
||||
:placeholder="t('Please select expiration date')"
|
||||
/>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Expiration') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
v-model="formModel.expiry"
|
||||
:min="minExpiry"
|
||||
:placeholder="t('Please select expiration date')"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Name') }}</Label>
|
||||
<Input v-model="formModel.name" autocomplete="off" />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.name" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Description') }}</Label>
|
||||
<Input v-model="formModel.description" autocomplete="off" />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Description') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.description" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 rounded-md border p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Use Main Account Policy') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('Automatically inherit the main account policy when enabled.') }}
|
||||
</p>
|
||||
</div>
|
||||
<Field orientation="responsive" class="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('Use Main Account Policy') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('Automatically inherit the main account policy when enabled.') }}
|
||||
</p>
|
||||
</FieldLabel>
|
||||
<FieldContent class="flex justify-end">
|
||||
<Switch v-model:checked="formModel.impliedPolicy" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Status') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formModel.accountStatus === 'on' ? t('Available') : t('Disabled') }}
|
||||
</p>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="responsive" class="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('Status') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formModel.accountStatus === 'on' ? t('Available') : t('Disabled') }}
|
||||
</p>
|
||||
</FieldLabel>
|
||||
<FieldContent class="flex justify-end">
|
||||
<Switch v-model:checked="statusBoolean" />
|
||||
</div>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div v-if="!formModel.impliedPolicy" class="space-y-2">
|
||||
<Label>{{ t('Current User Policy') }}</Label>
|
||||
<json-editor v-model="formModel.policy" />
|
||||
</div>
|
||||
<Field v-if="!formModel.impliedPolicy">
|
||||
<FieldLabel>{{ t('Current User Policy') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<json-editor v-model="formModel.policy" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="cancelEdit">
|
||||
@@ -71,7 +83,7 @@ import { Button } from '@/components/ui/button'
|
||||
|
||||
import DateTimePicker from '@/components/datetime-picker.vue'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="w-full sm:max-w-xs">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Account')" clearable class="w-full" />
|
||||
</div>
|
||||
<Button variant="secondary" class="inline-flex items-center gap-2" @click="addItem">
|
||||
<Button variant="outline" class="inline-flex items-center gap-2" @click="addItem">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Add Account') }}
|
||||
</Button>
|
||||
@@ -34,15 +34,10 @@
|
||||
<TableCell class="text-center">{{ account.name || '-' }}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" @click="openEditItem(account)">
|
||||
<Button variant="outline" size="sm" class="h-8 w-8 p-0" @click="openEditItem(account)">
|
||||
<Icon class="size-4" name="ri:edit-2-line" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0 text-destructive"
|
||||
@click="confirmDelete(account)"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-8 w-8 p-0 text-destructive border-destructive" @click="confirmDelete(account)">
|
||||
<Icon class="size-4" name="ri:delete-bin-5-line" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -63,44 +58,56 @@
|
||||
<template v-else-if="editType === 'add'">
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Access Key') }}</Label>
|
||||
<Input v-model="formModel.accessKey" autocomplete="off" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Secret Key') }}</Label>
|
||||
<Input v-model="formModel.secretKey" type="password" autocomplete="off" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Expiration') }}</Label>
|
||||
<DateTimePicker v-model="formModel.expiry" :min="minExpiry" :placeholder="t('Please select expiration date')" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Name') }}</Label>
|
||||
<Input v-model="formModel.name" autocomplete="off" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label>{{ t('Description') }}</Label>
|
||||
<Textarea v-model="formModel.description" :rows="3" />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.accessKey" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Secret Key') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.secretKey" type="password" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Expiration') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker v-model="formModel.expiry" :min="minExpiry" :placeholder="t('Please select expiration date')" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="formModel.name" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel>{{ t('Description') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea v-model="formModel.description" :rows="3" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ t('Use main account policy') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('Automatically inherit the main account policy when enabled.') }}
|
||||
</p>
|
||||
</div>
|
||||
<Field orientation="responsive" class="items-start rounded-md border px-3 py-2">
|
||||
<FieldLabel class="space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('Use main account policy') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('Automatically inherit the main account policy when enabled.') }}
|
||||
</p>
|
||||
</FieldLabel>
|
||||
<FieldContent class="flex justify-end">
|
||||
<Switch v-model:checked="formModel.impliedPolicy" />
|
||||
</div>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div v-if="!formModel.impliedPolicy" class="space-y-2">
|
||||
<Label>{{ t('Current User Policy') }}</Label>
|
||||
<json-editor v-model="formModel.policy" />
|
||||
</div>
|
||||
<Field v-if="!formModel.impliedPolicy">
|
||||
<FieldLabel>{{ t('Current User Policy') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<json-editor v-model="formModel.policy" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="cancelAdd">{{ t('Cancel') }}</Button>
|
||||
@@ -109,29 +116,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<users-user-acedit
|
||||
v-if="editStatus && editType === 'edit'"
|
||||
:user="editData"
|
||||
@search="handleEditFinished"
|
||||
/>
|
||||
<user-access-key-edit-form v-if="editStatus && editType === 'edit'" :user="editData" @search="handleEditFinished" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import DateTimePicker from '@/components/datetime-picker.vue'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { makeRandomString } from '~/utils/functions'
|
||||
import { useDialog, useMessage } from '~/composables/ui'
|
||||
import { makeRandomString } from '~/utils/functions'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
@@ -288,7 +291,7 @@ const validateForm = () => {
|
||||
|
||||
const submitForm = async () => {
|
||||
if (editType.value === 'edit') {
|
||||
// 交由 users-user-acedit 处理
|
||||
// 交由 user-acedit 处理
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,10 +309,10 @@ const submitForm = async () => {
|
||||
}
|
||||
const res = await createServiceAccount(payload)
|
||||
message.success(t('Add Success'))
|
||||
cancelAdd()
|
||||
emit('search')
|
||||
emit('notice', res)
|
||||
loadAccounts()
|
||||
cancelAdd()
|
||||
emit('search')
|
||||
emit('notice', res)
|
||||
loadAccounts()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t('Add Failed'))
|
||||
@@ -14,27 +14,27 @@
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="groups" class="mt-0">
|
||||
<users-user-groups :user="user" @search="refreshUser" />
|
||||
<user-groups :user="user" @search="refreshUser" />
|
||||
</TabsContent>
|
||||
<TabsContent value="policy" class="mt-0">
|
||||
<users-user-policies :user="user" @search="refreshUser" />
|
||||
<user-policies :user="user" @search="refreshUser" />
|
||||
</TabsContent>
|
||||
<TabsContent value="account" class="mt-0">
|
||||
<users-user-account :user="user" @search="refreshUser" @notice="noticeDialog" />
|
||||
<user-account :user="user" @search="refreshUser" @notice="noticeDialog" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<users-user-notice ref="noticeRef" @search="refreshUser" />
|
||||
<user-notice ref="noticeRef" @search="refreshUser" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const visible = ref(false)
|
||||
@@ -2,64 +2,52 @@
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Group')" clearable class="w-full" />
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Group')" clearable class="max-w-xs" />
|
||||
</div>
|
||||
<Button variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Button variant="outline" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Edit Group') }}
|
||||
</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 Group') }}</Label>
|
||||
<Popover v-model:open="groupSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Select Group')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedGroupLabels.length ? selectedGroupLabels.join(', ') : t('Select Group') }}
|
||||
</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 Group')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in groups"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => toggleGroup(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="selectedGroups.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedGroups.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedGroups" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field class="flex w-full flex-col gap-2">
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Select Group') }}</FieldLabel>
|
||||
<FieldContent class="space-y-2">
|
||||
<Popover v-model:open="groupSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="outline" class="min-h-10 justify-between gap-2" :aria-label="t('Select Group')">
|
||||
<span class="truncate">
|
||||
{{ selectedGroupLabels.length ? selectedGroupLabels.join(', ') : t('Select Group') }}
|
||||
</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 Group')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="option in groups" :key="option.value" :value="option.label" @select="() => toggleGroup(option.value)">
|
||||
<Icon name="ri:check-line" class="mr-2 size-4" :class="selectedGroups.includes(option.value) ? 'opacity-100' : 'opacity-0'" />
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedGroups.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedGroups" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div class="flex items-center gap-2 sm:self-start">
|
||||
<Button variant="ghost" @click="cancelEditing">
|
||||
{{ t('Cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" :loading="submitting" @click="changeMembers">
|
||||
<Button variant="outline" :loading="submitting" @click="changeMembers">
|
||||
{{ t('Submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,7 +80,7 @@ import { Icon } from '#components'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
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'
|
||||
@@ -2,107 +2,92 @@
|
||||
<Modal v-model="visible" :title="t('Create User')" size="lg" :close-on-backdrop="false">
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('User Name') }}</Label>
|
||||
<Input v-model="editForm.accessKey" autocomplete="off" />
|
||||
<p v-if="errors.accessKey" class="text-xs text-destructive">{{ errors.accessKey }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Password') }}</Label>
|
||||
<Input v-model="editForm.secretKey" type="password" autocomplete="off" />
|
||||
<p v-if="errors.secretKey" class="text-xs text-destructive">{{ errors.secretKey }}</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('User Name') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="editForm.accessKey" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.accessKey" class="text-destructive">
|
||||
{{ errors.accessKey }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Password') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input v-model="editForm.secretKey" type="password" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription v-if="errors.secretKey" class="text-destructive">
|
||||
{{ errors.secretKey }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Groups') }}</Label>
|
||||
<Popover v-model:open="groupSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Groups')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedGroupLabels.length ? selectedGroupLabels.join(', ') : t('Select Group') }}
|
||||
</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 Group')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in groupsList"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => toggleGroup(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="editForm.groups.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="editForm.groups.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in editForm.groups" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Groups') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover v-model:open="groupSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="outline" class="min-h-10 justify-between gap-2" :aria-label="t('Groups')">
|
||||
<span class="truncate">
|
||||
{{ selectedGroupLabels.length ? selectedGroupLabels.join(', ') : t('Select Group') }}
|
||||
</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 Group')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="option in groupsList" :key="option.value" :value="option.label" @select="() => toggleGroup(option.value)">
|
||||
<Icon name="ri:check-line" class="mr-2 size-4" :class="editForm.groups.includes(option.value) ? 'opacity-100' : 'opacity-0'" />
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="editForm.groups.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in editForm.groups" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Policy') }}</Label>
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Policy')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in policiesList"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => togglePolicy(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="editForm.policies.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="editForm.policies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in editForm.policies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Policy') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="outline" class="min-h-10 justify-between gap-2" :aria-label="t('Policy')">
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="option in policiesList" :key="option.value" :value="option.label" @select="() => togglePolicy(option.value)">
|
||||
<Icon name="ri:check-line" class="mr-2 size-4" :class="editForm.policies.includes(option.value) ? 'opacity-100' : 'opacity-0'" />
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="editForm.policies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in editForm.policies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -119,16 +104,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
@@ -1,14 +1,18 @@
|
||||
<template>
|
||||
<Modal v-model="visible" :title="t('New user has been created')" size="md" :close-on-backdrop="false">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Access Key') }}</Label>
|
||||
<copy-input v-model="accessKey" class="w-full" :readonly="true" :copy-icon="true" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('Secret Key') }}</Label>
|
||||
<copy-input v-model="secretkey" class="w-full" :readonly="true" :copy-icon="true" />
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<copy-input v-model="accessKey" class="w-full" :readonly="true" :copy-icon="true" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Secret Key') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<copy-input v-model="secretkey" class="w-full" :readonly="true" :copy-icon="true" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -22,12 +26,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import Modal from '@/components/modal.vue'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { download } from '@/utils/export-file'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '~/components/modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const visible = ref(false)
|
||||
@@ -2,64 +2,52 @@
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Policy')" clearable class="w-full" />
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Policy')" clearable class="max-w-xs" />
|
||||
</div>
|
||||
<Button variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Button variant="outline" class="inline-flex items-center gap-2" @click="startEditing">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Edit Policy') }}
|
||||
</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 Group') }}</Label>
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="min-h-10 justify-between gap-2"
|
||||
:aria-label="t('Select Group')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in policies"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="() => togglePolicy(option.value)"
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="mr-2 size-4"
|
||||
:class="selectedPolicies.includes(option.value) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedPolicies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedPolicies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Field class="flex w-full flex-col gap-2">
|
||||
<FieldLabel class="text-sm font-medium">{{ t('Select Group') }}</FieldLabel>
|
||||
<FieldContent class="space-y-2">
|
||||
<Popover v-model:open="policySelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="outline" class="min-h-10 justify-between gap-2" :aria-label="t('Select Group')">
|
||||
<span class="truncate">
|
||||
{{ selectedPolicyLabels.length ? selectedPolicyLabels.join(', ') : t('Select user group policies') }}
|
||||
</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 Policy')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t('No Data') }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="option in policies" :key="option.value" :value="option.label" @select="() => togglePolicy(option.value)">
|
||||
<Icon name="ri:check-line" class="mr-2 size-4" :class="selectedPolicies.includes(option.value) ? 'opacity-100' : 'opacity-0'" />
|
||||
<span>{{ option.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedPolicies.length" class="flex flex-wrap gap-2">
|
||||
<Badge v-for="value in selectedPolicies" :key="value" variant="secondary">{{ value }}</Badge>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div class="flex items-center gap-2 sm:self-start">
|
||||
<Button variant="ghost" @click="cancelEditing">
|
||||
{{ t('Cancel') }}
|
||||
</Button>
|
||||
<Button variant="secondary" :loading="submitting" @click="changePolicies">
|
||||
<Button variant="outline" :loading="submitting" @click="changePolicies">
|
||||
{{ t('Submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,7 +80,7 @@ import { Icon } from '#components'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
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'
|
||||
@@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search User Group')" clearable class="w-full max-w-md" />
|
||||
<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: boolean) => 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '#components'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
const group = useGroups()
|
||||
|
||||
interface GroupRow {
|
||||
name: string
|
||||
}
|
||||
|
||||
const searchTerm = ref('')
|
||||
const listData = ref<GroupRow[]>([])
|
||||
const checkedKeys = ref<string[]>([])
|
||||
|
||||
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) => ({
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
/** **********************************添加 */
|
||||
const newItemRef = ref()
|
||||
const newItemVisible = ref(false)
|
||||
|
||||
function addUserGroup() {
|
||||
newItemVisible.value = true
|
||||
}
|
||||
|
||||
/** **********************************分配策略 */
|
||||
const policiesRef = ref()
|
||||
const allocationPolicy = () => {
|
||||
policiesRef.value.openDialog()
|
||||
}
|
||||
|
||||
const changePoliciesSuccess = () => {
|
||||
checkedKeys.value = []
|
||||
}
|
||||
/** **********************************修改 */
|
||||
const editItemRef = ref()
|
||||
function openEditItem(row: GroupRow) {
|
||||
editItemRef.value.openDialog(row)
|
||||
}
|
||||
|
||||
/** ***********************************删除 */
|
||||
async function deleteItem(row: GroupRow) {
|
||||
try {
|
||||
// 获取组的成员
|
||||
const info = await group.getGroup(row.name)
|
||||
if (info.members.length) {
|
||||
message.error('请先清空组成员')
|
||||
return
|
||||
}
|
||||
// 清空组的成员
|
||||
await group.updateGroupMembers({
|
||||
group: row.name,
|
||||
members: info.members,
|
||||
isRemove: true,
|
||||
groupStatus: 'enabled',
|
||||
})
|
||||
message.success(t('Delete Success'))
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** ************************************批量删除 */
|
||||
function rowKey(row: GroupRow): string {
|
||||
return row.name
|
||||
}
|
||||
|
||||
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: async () => {
|
||||
if (!checkedKeys.value.length) {
|
||||
message.error(t('Please select at least one item'))
|
||||
return
|
||||
}
|
||||
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>
|
||||
@@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Access User')" clearable class="w-full max-w-md" />
|
||||
<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: boolean) => 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '#components'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
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 { listUsers, deleteUser } = useUsers()
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
|
||||
interface UserRow {
|
||||
accessKey: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const searchTerm = ref('')
|
||||
const listData = ref<UserRow[]>([])
|
||||
const checkedKeys = ref<string[]>([])
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// 获取数据
|
||||
const getDataList = async () => {
|
||||
try {
|
||||
const res = await listUsers()
|
||||
|
||||
listData.value = Object.entries(res).map(([username, info]) => ({
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
/** **********************************添加 */
|
||||
const newItemRef = ref()
|
||||
|
||||
function addUserItem() {
|
||||
newItemRef.value.openDialog()
|
||||
}
|
||||
|
||||
/** **********************************添加到的分组 */
|
||||
const addToGroup = () => { }
|
||||
|
||||
/** **********************************修改 */
|
||||
const editItemRef = ref()
|
||||
function openEditItem(row: any) {
|
||||
editItemRef.value.openDialog(row)
|
||||
}
|
||||
/** ***********************************删除 */
|
||||
async function deleteItem(row: any) {
|
||||
try {
|
||||
await deleteUser(row.accessKey)
|
||||
message.success(t('Delete Success'))
|
||||
getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** ************************************批量删除 */
|
||||
function rowKey(row: any): string {
|
||||
return row.accessKey
|
||||
}
|
||||
|
||||
function deleteByList() {
|
||||
dialog.error({
|
||||
title: t('Warning'),
|
||||
content: t('Are you sure you want to delete all selected users?'),
|
||||
positiveText: t('Confirm'),
|
||||
negativeText: t('Cancel'),
|
||||
onPositiveClick: async () => {
|
||||
if (!checkedKeys.value.length) {
|
||||
message.error(t('Please select at least one item'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 循环遍历删除
|
||||
await Promise.all(checkedKeys.value.map(item => deleteUser(item)))
|
||||
checkedKeys.value = []
|
||||
message.success(t('Delete Success'))
|
||||
nextTick(() => {
|
||||
getDataList()
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -22,6 +22,11 @@ export default [
|
||||
to: '/users',
|
||||
icon: 'ri:file-user-line',
|
||||
},
|
||||
{
|
||||
label: 'User Groups',
|
||||
to: '/user-groups',
|
||||
icon: 'ri:group-line',
|
||||
},
|
||||
{
|
||||
label: 'Import/Export',
|
||||
to: '/import-export',
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Shadcn-Vue Migration Plan
|
||||
|
||||
This document tracks the Naive UI → shadcn-vue migration for the RustFS console. The migration focuses on a visual refresh without altering business logic or flows.
|
||||
|
||||
## Phase Overview
|
||||
|
||||
1. **Foundation** – remove Naive UI plugins, wire up shadcn providers, and ensure global theming, i18n, and layout tokens remain intact.
|
||||
2. **Layout & Navigation** – rebuild the default layout and sidebar based on Sidebar07 while keeping Pinia-driven collapse behaviour.
|
||||
3. **Shared Primitives** – introduce `app-*` components that wrap shadcn primitives to cover buttons, cards, dialogs, drawers, forms, tables, and feedback mechanisms used across screens.
|
||||
4. **Feature Screens** – replace Naive UI usage page-by-page, preserving interactions and API calls; migrate bespoke styles only when still needed.
|
||||
5. **Cleanup & Validation** – remove Naive UI dependencies, stale utilities, and update docs/tests before a final regression pass.
|
||||
|
||||
Each phase will be committed independently on `refactor/shadcn-vue` to keep rollback simple.
|
||||
|
||||
## Component Mapping
|
||||
|
||||
| Category | Naive UI usage | shadcn-vue / plan |
|
||||
| --- | --- | --- |
|
||||
| Providers | `NConfigProvider`, `NMessageProvider`, `NDialogProvider`, `NNotificationProvider`, `useMessage`, `useDialog`, `useNotification` | Replace with a root `AppUiProvider` that wraps `ThemeProvider`, `TooltipProvider`, and `Toaster` (`components/ui/sonner`). Provide composables for toast/dialog using shadcn Dialog + AlertDialog. |
|
||||
| Layout | `NLayout`, `NLayoutSider`, `NSpace`, `NFlex` | Adopt Sidebar07 structure using `SidebarProvider`, `AppSidebar`, and Tailwind flex utilities; remove redundant spacing classes. |
|
||||
| Navigation | `NMenu`, `NDropdown` | Build `AppSidebarMenu` using shadcn Sidebar primitives; for dropdowns use `components/ui/dropdown-menu`. |
|
||||
| Form shell | `NForm`, `NFormItem`, `NFormItemGi`, `NFormItemGridItem`, `NGrid`, `NGi` | Use `vee-validate` (already installed) with `components/ui/form`. Create wrappers (`app-form`, `app-form-field`, helpers) that match current inline/grid layout via Tailwind grid/flex utilities. |
|
||||
| Inputs | `NInput`, `NInputNumber`, `NSelect`, `NDatePicker`, `NCheckbox`, `NCheckboxGroup`, `NRadio`, `NRadioGroup`, `NSwitch`, `NDynamicInput`, `NUpload`, `NUploadDragger`, `NInputGroup`, `NInputGroupLabel`, `NP`, `NText` | Map to shadcn `Input`, `NumberField`, `Select`, `Calendar/Popover`, `Checkbox`, `RadioGroup`, `Switch`, `TagsInput`. Implement custom wrappers for dynamic list fields and upload (likely using existing uploader logic + `Dropzone`). Replace `NP/NText` with semantic HTML + `Typography` utilities. |
|
||||
| Buttons | `NButton`, `NButtonGroup` | Use shadcn `Button` / `ButtonGroup` directly; add Tailwind utilities or inline spinner when needed for loading states. |
|
||||
| Feedback | `NModal`, `NDrawer`, `NAlert`, `NTooltip`, `NEmpty`, `NProgress`, `NSpin`, `NStatistic`, `NBreadcrumb` | Use shadcn `Dialog`, `Drawer`, `Alert`, `Tooltip`, `Empty` block, `Progress`, `Spinner` etc. Create `app-stat`/`app-empty` wrappers where gaps exist. |
|
||||
| Data display | `NCard`, `NList`, `NThing`, `NBadge`, `NTag`, `NDescriptions`, `NDescriptionsItem`, `NCarousel`, `NCollapse`, `NCollapseItem` | Replace with shadcn `Card`, `Badge`, `Accordion`, `Tabs` etc. Implement `AppDescriptionList` (simple definition list). Use `Carousel` block already present. |
|
||||
| Tables | `NDataTable`, `NVirtualList` | Implement `DataTable` powered by `@tanstack/vue-table` + `ScrollArea`. Support selection, inline actions, slot-based cell rendering. Provide compatible props for datasets currently using render functions. |
|
||||
| Misc | `NDrawerContent`, `NScrollbar`, `NPageHeader`, `NPopover`, `NPopconfirm` | Use `DrawerContent`, `ScrollArea`, `PageHeader` replaced by `div` + `Breadcrumb`, shadcn `Popover`, and build `AppConfirmDialog` on top of AlertDialog. |
|
||||
|
||||
### Additional Dependencies
|
||||
|
||||
- `@tanstack/vue-table`, `@tanstack/match-sorter-utils`, and `@tanstack/vue-virtual` for the shared table abstraction.
|
||||
- Possibly `@headlessui/vue` or lightweight packages are **not** required because shadcn already provides primitives through Reka/ Radix.
|
||||
|
||||
## Shared Components To Introduce
|
||||
|
||||
- `components/providers/AppUiProvider.vue` – wraps shadcn theme providers and exports composables for toasts/dialogs.
|
||||
- `components/app-sidebar.vue` + related items (menu, user dropdown host) following Sidebar07 markup.
|
||||
- Form/layout helpers in `components/app-*.vue` (card, form, field grid) for consistent structure.
|
||||
- Shared overlays like `components/modal.vue`, `drawer.vue`, and confirmation dialogs.
|
||||
- `components/DataTable` module with column definitions, toolbar templates, and selection helpers.
|
||||
- Presentation helpers such as `components/app-description-list.vue`, `empty-state.vue`, `spinner.vue`, and related statistic cards.
|
||||
|
||||
These wrappers allow feature screens to migrate with minimal churn and keep visual consistency.
|
||||
|
||||
## Page Migration Order
|
||||
|
||||
1. **Access Keys / Users / Policies** – heavy data table usage; verify DataTable handles selection and actions.
|
||||
2. **Objects & Buckets** – exercise drawers/modals, dynamic input, and descriptions.
|
||||
3. **Lifecycle / Replication / Events** – validate complex forms, tabs, accordions.
|
||||
4. **Performance Dashboard** – ensure stats, badges, carousel translations remain coherent.
|
||||
5. **Auth / Settings / License** – simpler forms and cards to finish the sweep.
|
||||
|
||||
After each page group migrates, commit & push to `refactor/shadcn-vue`.
|
||||
|
||||
## Cleanup Checklist
|
||||
|
||||
- [x] Remove `nuxtjs-naive-ui`, `naive-ui`, `auto-imports` config, and generated component declarations.
|
||||
- [x] Drop obsolete CSS tied to Naive overrides once equivalents exist.
|
||||
- [x] Update README to reflect the new UI stack.
|
||||
- [x] Run lint/tests plus a targeted manual regression of menus, forms, uploads, and table interactions.
|
||||
|
||||
## Remaining TODO (2025-10-27)
|
||||
|
||||
- [x] Object task management: `components/object/task-stats.vue`, `components/object/upload/task/item.vue`, `components/object/delete/task/item.vue`, and finish `components/object/upload/picker.vue`.
|
||||
- [x] Bucket dialogs: migrate `components/buckets/new-form.vue` and `components/buckets/info.vue`.
|
||||
- [x] User management suite: group/user edit & policy components under `components/users/**` (forms, tables, modals).
|
||||
- [x] Performance dashboard: refactor `pages/performance/index.vue` to shadcn primitives.
|
||||
- [x] License center: migrate `pages/license/index.vue` & `pages/license/components/license-article.vue`.
|
||||
- [x] SSE monitor & metrics: convert `pages/sse/index.vue`.
|
||||
- [x] System configuration tooling: update `pages/config.vue`.
|
||||
- [x] KMS test utilities: migrate `pages/kms-test.vue`.
|
||||
- [x] Final pass: remove Naive UI resolver/module, scrub `<n-*>` usage (`rg "<n-"` should return nothing), and tidy shared components.
|
||||
|
||||
This plan will guide the subsequent implementation steps while keeping the migration reviewable.
|
||||
@@ -1,25 +0,0 @@
|
||||
# ShadCN Vue Migration Todo
|
||||
|
||||
- [x] Replace global layout shell (`layouts/default.vue`) to use `components/app-sidebar.vue` built with ShadCN Sidebar primitives and update the layout structure similar to Sidebar07.
|
||||
- [x] Build `components/app-sidebar.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.
|
||||
- [x] Convert shared form components and high-use Naive UI elements (buttons, inputs, tables, etc.) to their ShadCN equivalents, introducing reusable wrappers under `components/` when helpful.
|
||||
- [x] Establish shared wrappers (`modal.vue`, `selector.vue`, etc.) directly under `components/`; buttons/inputs now import shadcn primitives directly.
|
||||
- [x] Build reusable data table primitives (`DataTable`, pagination, `useDataTable`) powered by TanStack.
|
||||
- [x] Convert `components/copy-input.vue` to the new wrappers and toast API.
|
||||
- [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.
|
||||
- [x] Migrate Access Keys management (list + modals) to shared ShadCN wrappers and data table.
|
||||
- [x] Migrate IAM Policies list and editor modal to shared ShadCN wrappers.
|
||||
- [x] Migrate Event Destinations list and creation modal to shared ShadCN wrappers.
|
||||
- [x] Migrate Lifecycle management (list + creation modal) onto shared ShadCN components.
|
||||
- [x] Migrate Replication management (list + creation modal) onto shared ShadCN components.
|
||||
- [x] Migrate Tier management (list + modals) onto shared ShadCN components.
|
||||
- [x] Migrate IAM import/export workflows to ShadCN components and new upload zone.
|
||||
- [x] Migrate Settings page to shared ShadCN components.
|
||||
- [x] Migrate Auth login page to shared ShadCN components.
|
||||
- [x] Migrate feature-specific views to consume the new shared components, auditing for leftover Naive UI imports and styles. (`rg "<n-"` returns no matches.)
|
||||
- [x] Remove Naive UI dependencies and configuration, ensuring ESLint/Vitest/build succeed.
|
||||
+11
-14
@@ -3,16 +3,16 @@
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">{{ t('Access Keys') }}</h1>
|
||||
<template #actions>
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Access Key')" clearable class="max-w-sm" />
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Access Key')" clearable class="max-w-xs" />
|
||||
<Button variant="outline" @click="changePasswordVisible = true">
|
||||
<Icon name="ri:key-2-line" class="size-4" />
|
||||
<span>{{ t('Change Password') }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" v-show="selectedKeys.length" :disabled="!selectedKeys.length" @click="deleteSelected">
|
||||
<Button variant="outline" v-if="selectedKeys.length" :disabled="!selectedKeys.length" @click="deleteSelected">
|
||||
<Icon name="ri:delete-bin-5-line" class="size-4" />
|
||||
<span>{{ t('Delete Selected') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" @click="addItem">
|
||||
<Button variant="outline" @click="addItem">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Access Key') }}</span>
|
||||
</Button>
|
||||
@@ -25,10 +25,10 @@
|
||||
<DataTablePagination :table="table" class="px-2 py-3" />
|
||||
</div>
|
||||
|
||||
<NewItem ref="newItemRef" v-model:visible="newItemVisible" @search="refresh" @notice="noticeDialog" />
|
||||
<EditItem ref="editItemRef" @search="refresh" />
|
||||
<ChangePassword ref="changePasswordModalRef" v-model:visible="changePasswordVisible" />
|
||||
<users-user-notice ref="noticeRef" @search="refresh" />
|
||||
<access-keys-new-item ref="newItemRef" v-model:visible="newItemVisible" @search="refresh" @notice="noticeDialog" />
|
||||
<access-keys-edit-item ref="editItemRef" @search="refresh" />
|
||||
<access-keys-change-password ref="changePasswordModalRef" v-model:visible="changePasswordVisible" />
|
||||
<user-notice ref="noticeRef" @search="refresh" />
|
||||
</page>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Icon } from '#components'
|
||||
import { ChangePassword, EditItem, NewItem } from '@/components/access-keys'
|
||||
import DataTablePagination from '@/components/data-table/data-table-pagination.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
@@ -64,8 +63,6 @@ const data = ref<RowData[]>([])
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
const TableButton = Button as unknown as any
|
||||
|
||||
const openEditItem = (row: RowData) => {
|
||||
editItemRef.value?.openDialog(row)
|
||||
}
|
||||
@@ -90,13 +87,13 @@ const columns: ColumnDef<RowData>[] = [
|
||||
checked: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
class: 'translate-y-[2px]'
|
||||
class: 'translate-y-0.5'
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
checked: row.getIsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
class: 'translate-y-[2px]'
|
||||
class: 'translate-y-0.5'
|
||||
}),
|
||||
size: 48,
|
||||
},
|
||||
@@ -139,12 +136,12 @@ const columns: ColumnDef<RowData>[] = [
|
||||
enableHiding: false,
|
||||
cell: ({ row }) =>
|
||||
h('div', { class: 'flex justify-center gap-2' }, [
|
||||
h(TableButton, {
|
||||
h(Button, {
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
onClick: () => openEditItem(row.original),
|
||||
}, () => [h(Icon, { name: 'ri:edit-2-line', class: 'size-4' }), h('span', t('Edit'))]),
|
||||
h(TableButton, {
|
||||
h(Button, {
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
onClick: () => confirmDeleteSingle(row.original),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 class="text-2xl font-bold">{{ t('Buckets') }}</h1>
|
||||
<template #actions>
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search')" clearable class="max-w-sm" />
|
||||
<Button variant="secondary" @click="formVisible = true">
|
||||
<Button variant="outline" @click="formVisible = true">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Create Bucket') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="w-full sm:max-w-xs">
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search')" clearable class="w-full" />
|
||||
</div>
|
||||
<Button variant="secondary" @click="addForm">
|
||||
<Button variant="outline" @click="addForm">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Event Destination') }}</span>
|
||||
</Button>
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<DataTable :table="table" :is-loading="pending" :empty-title="t('No Destinations')" :empty-description="t('Create an event destination to forward notifications.')" />
|
||||
|
||||
<events-target-new ref="newFormRef" @search="() => refresh()" />
|
||||
<events-target-new-form ref="newFormRef" @search="() => refresh()" />
|
||||
</page>
|
||||
</template>
|
||||
|
||||
|
||||
+22
-24
@@ -3,29 +3,27 @@
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">{{ t('Events') }}</h1>
|
||||
<template #actions>
|
||||
<ActionBar class="w-full justify-end gap-2">
|
||||
<Label for="bucket-select">{{ t('Bucket') }}</Label>
|
||||
<div class="max-w-xs flex-1">
|
||||
<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>
|
||||
<Button type="button" variant="secondary" @click="handleNew">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
<span>{{ t('Add Event Subscription') }}</span>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="handleRefresh" :disabled="loading">
|
||||
<Icon class="size-4" name="ri:refresh-line" />
|
||||
<span>{{ t('Refresh') }}</span>
|
||||
</Button>
|
||||
</ActionBar>
|
||||
<Label for="bucket-select">{{ t('Bucket') }}</Label>
|
||||
<div class="max-w-xs flex-1">
|
||||
<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>
|
||||
<Button type="button" variant="outline" @click="handleNew">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
<span>{{ t('Add Event Subscription') }}</span>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="handleRefresh" :disabled="loading">
|
||||
<Icon class="size-4" name="ri:refresh-line" />
|
||||
<span>{{ t('Refresh') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</page-header>
|
||||
|
||||
@@ -68,7 +66,7 @@
|
||||
<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)">
|
||||
<Button type="button" size="sm" variant="outline" class="gap-2" @click="event => handleRowDelete(row, event)">
|
||||
<Icon class="size-4" name="ri:delete-bin-7-line" />
|
||||
{{ t('Delete') }}
|
||||
</Button>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<page>
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">KMS API Test Page</h1>
|
||||
<template #description>
|
||||
<p class="text-gray-600 dark:text-gray-400">Test page for KMS functionality after API updates</p>
|
||||
</template>
|
||||
</page-header>
|
||||
|
||||
<div>
|
||||
<Card class="mb-6 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>KMS API Test Suite</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Button @click="testServiceStatus" :loading="testing.status">Test Service Status</Button>
|
||||
<Button @click="testConfiguration" :loading="testing.config">Test Configuration</Button>
|
||||
<Button @click="testKeyList" :loading="testing.keys">Test Key List</Button>
|
||||
<Button @click="testClearCache" :loading="testing.cache">Test Clear Cache</Button>
|
||||
</div>
|
||||
|
||||
<Card v-if="testResults.length > 0" class="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Test Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div
|
||||
v-for="(result, index) in testResults"
|
||||
:key="index"
|
||||
class="rounded border-l-4 p-3"
|
||||
:class="result.success ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/10' : 'border-rose-500 bg-rose-50 dark:bg-rose-900/10'"
|
||||
>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium">{{ result.test }}</h4>
|
||||
<p class="text-sm text-muted-foreground">{{ result.message }}</p>
|
||||
<div v-if="result.data" class="mt-2 text-sm text-muted-foreground">
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-primary">View Response Data</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">{{ JSON.stringify(result.data, null, 2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="result.success ? 'secondary' : 'destructive'" class="self-start">
|
||||
{{ result.success ? 'PASS' : 'FAIL' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>API Documentation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Updated KMS API documentation is available in the project's docs folder.
|
||||
</p>
|
||||
<Button
|
||||
as="a"
|
||||
href="/docs/kms/frontend-api-guide-zh.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outline"
|
||||
class="mt-2 w-fit"
|
||||
>
|
||||
View API Documentation
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMessage } from '@/composables/ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const { getKMSStatus, getConfiguration, getKeyList, clearCache } = useSSE()
|
||||
const message = useMessage()
|
||||
|
||||
const testing = reactive({
|
||||
status: false,
|
||||
config: false,
|
||||
keys: false,
|
||||
cache: false,
|
||||
})
|
||||
|
||||
const testResults = ref<Array<{ test: string; success: boolean; message: string; data?: any; timestamp: Date }>>([])
|
||||
|
||||
const addTestResult = (test: string, success: boolean, message: string, data?: any) => {
|
||||
testResults.value.unshift({ test, success, message, data, timestamp: new Date() })
|
||||
if (testResults.value.length > 10) {
|
||||
testResults.value = testResults.value.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
const testServiceStatus = async () => {
|
||||
testing.status = true
|
||||
try {
|
||||
const result = await getKMSStatus()
|
||||
addTestResult('Service Status', true, `Service status: ${result.status}, Healthy: ${result.healthy}`, result)
|
||||
message.success('Service status test passed')
|
||||
} catch (error) {
|
||||
addTestResult('Service Status', false, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, error)
|
||||
message.error('Service status test failed')
|
||||
} finally {
|
||||
testing.status = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConfiguration = async () => {
|
||||
testing.config = true
|
||||
try {
|
||||
const result = await getConfiguration()
|
||||
addTestResult('Configuration', true, `Configuration loaded with backend: ${result.backend}`, result)
|
||||
message.success('Configuration test passed')
|
||||
} catch (error) {
|
||||
addTestResult('Configuration', false, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, error)
|
||||
message.error('Configuration test failed')
|
||||
} finally {
|
||||
testing.config = false
|
||||
}
|
||||
}
|
||||
|
||||
const testKeyList = async () => {
|
||||
testing.keys = true
|
||||
try {
|
||||
const result = await getKeyList()
|
||||
const keyCount = result.keys ? result.keys.length : 0
|
||||
addTestResult('Key List', true, `Found ${keyCount} keys, Truncated: ${result.truncated || false}`, result)
|
||||
message.success('Key list test passed')
|
||||
} catch (error) {
|
||||
addTestResult('Key List', false, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, error)
|
||||
message.error('Key list test failed')
|
||||
} finally {
|
||||
testing.keys = false
|
||||
}
|
||||
}
|
||||
|
||||
const testClearCache = async () => {
|
||||
testing.cache = true
|
||||
try {
|
||||
const result = await clearCache()
|
||||
addTestResult(
|
||||
'Clear Cache',
|
||||
result.status === 'success',
|
||||
`Cache clear result: ${result.status} - ${result.message}`,
|
||||
result,
|
||||
)
|
||||
message.success('Clear cache test completed')
|
||||
} catch (error) {
|
||||
addTestResult('Clear Cache', false, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, error)
|
||||
message.error('Clear cache test failed')
|
||||
} finally {
|
||||
testing.cache = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,14 +4,8 @@
|
||||
<h1 class="text-2xl font-bold">{{ t('Lifecycle') }}</h1>
|
||||
<template #actions>
|
||||
<ActionBar class="w-full justify-end gap-3 sm:w-auto">
|
||||
<BucketSelector
|
||||
v-model="bucketName"
|
||||
:options="bucketList"
|
||||
:placeholder="t('Please select bucket')"
|
||||
class="w-full sm:w-auto"
|
||||
selector-class="sm:w-56"
|
||||
/>
|
||||
<Button variant="secondary" @click="handleNew">
|
||||
<BucketSelector v-model="bucketName" :options="bucketList" :placeholder="t('Please select bucket')" class="w-full sm:w-auto" selector-class="sm:w-56" />
|
||||
<Button variant="outline" @click="handleNew">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Lifecycle Rule') }}</span>
|
||||
</Button>
|
||||
@@ -35,12 +29,12 @@ import { Button } from '@/components/ui/button'
|
||||
import { Icon } from '#components'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
import type { SelectOption } from '@/components/selector.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Bucket } from '@aws-sdk/client-s3'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SelectOption } from '~/components/selector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 class="text-2xl font-bold">{{ t('IAM Policies') }}</h1>
|
||||
<template #actions>
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search')" clearable class="max-w-sm" />
|
||||
<Button variant="secondary" @click="handleNew">
|
||||
<Button variant="outline" @click="handleNew">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('New Policy') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -4,14 +4,8 @@
|
||||
<h1 class="text-2xl font-bold">{{ t('Bucket Replication') }}</h1>
|
||||
<template #actions>
|
||||
<ActionBar class="w-full justify-end gap-3 sm:w-auto">
|
||||
<BucketSelector
|
||||
v-model="bucketName"
|
||||
:options="bucketList"
|
||||
:placeholder="t('Please select bucket')"
|
||||
class="w-full sm:w-auto"
|
||||
selector-class="sm:w-56"
|
||||
/>
|
||||
<Button variant="secondary" @click="openForm">
|
||||
<BucketSelector v-model="bucketName" :options="bucketList" :placeholder="t('Please select bucket')" class="w-full sm:w-auto" selector-class="sm:w-56" />
|
||||
<Button variant="outline" @click="openForm">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Replication Rule') }}</span>
|
||||
</Button>
|
||||
@@ -35,13 +29,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Icon } from '#components'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
import type { SelectOption } from '@/components/selector.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useBucket } from '@/composables/useBucket'
|
||||
import type { Bucket } from '@aws-sdk/client-s3'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SelectOption } from '~/components/selector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</page-header>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-end">
|
||||
<Button type="button" variant="secondary" class="inline-flex items-center gap-2" @click="openForm">
|
||||
<Button type="button" variant="outline" class="inline-flex items-center gap-2" @click="openForm">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Site') }}</span>
|
||||
</Button>
|
||||
|
||||
+139
-188
@@ -60,23 +60,12 @@
|
||||
<Icon name="ri:eye-line" class="mr-2 size-4" />
|
||||
{{ t('Details') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasConfiguration && (sseKmsForm.kms_status === 'Configured' || isErrorStatus(sseKmsForm.kms_status))"
|
||||
size="sm"
|
||||
variant="default"
|
||||
:loading="startingKMS"
|
||||
@click="startKMSService"
|
||||
>
|
||||
<Button v-if="hasConfiguration && (sseKmsForm.kms_status === 'Configured' || isErrorStatus(sseKmsForm.kms_status))" size="sm" variant="default" :loading="startingKMS"
|
||||
@click="startKMSService">
|
||||
<Icon name="ri:play-line" class="mr-2 size-4" />
|
||||
{{ t('Start KMS') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasConfiguration && sseKmsForm.kms_status === 'Running'"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:loading="stoppingKMS"
|
||||
@click="stopKMSService"
|
||||
>
|
||||
<Button v-if="hasConfiguration && sseKmsForm.kms_status === 'Running'" size="sm" variant="outline" :loading="stoppingKMS" @click="stopKMSService">
|
||||
<Icon name="ri:stop-line" class="mr-2 size-4" />
|
||||
{{ t('Stop KMS') }}
|
||||
</Button>
|
||||
@@ -171,165 +160,161 @@
|
||||
|
||||
<!-- 编辑模式表单 -->
|
||||
<form v-else class="space-y-6" @submit.prevent="saveConfiguration">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('KMS Type') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('HashiCorp Vault Transit Engine') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('KMS Type') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('HashiCorp Vault Transit Engine') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-address">{{ t('Vault Server Address') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-address" v-model="sseKmsForm.address" :placeholder="t('e.g., https://vault.example.com:8200')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="kms-address">{{ t('Vault Server Address') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-address" v-model="sseKmsForm.address" :placeholder="t('e.g., https://vault.example.com:8200')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Authentication Method') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<RadioGroup v-model="sseKmsForm.auth_type" class="grid gap-3 md:grid-cols-2">
|
||||
<label
|
||||
v-for="option in authTypeOptions"
|
||||
:key="option.value"
|
||||
class="flex items-start gap-3 rounded-md border border-border/50 p-3"
|
||||
>
|
||||
<RadioGroupItem :value="option.value" class="mt-0.5" />
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="text-xs text-muted-foreground">
|
||||
{{ option.description }}
|
||||
<Field>
|
||||
<FieldLabel>{{ t('Authentication Method') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<RadioGroup v-model="sseKmsForm.auth_type" class="grid gap-3 md:grid-cols-2">
|
||||
<label v-for="option in authTypeOptions" :key="option.value" class="flex items-start gap-3 rounded-md border border-border/50 p-3">
|
||||
<RadioGroupItem :value="option.value" class="mt-0.5" />
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="text-xs text-muted-foreground">
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field v-if="sseKmsForm.auth_type === 'token'">
|
||||
<FieldLabel for="kms-token">{{ t('Vault Token') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-token" v-model="sseKmsForm.vault_token" type="password" autocomplete="off" :placeholder="t('Enter your Vault authentication token')" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Required: Vault authentication token') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div v-if="sseKmsForm.auth_type === 'approle'" class="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="kms-role-id">{{ t('Role ID') }}</FieldLabel>
|
||||
<Field v-if="sseKmsForm.auth_type === 'token'">
|
||||
<FieldLabel for="kms-token">{{ t('Vault Token') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-role-id" v-model="sseKmsForm.vault_app_role_id" :placeholder="t('Enter AppRole Role ID')" autocomplete="off" />
|
||||
<Input id="kms-token" v-model="sseKmsForm.vault_token" type="password" autocomplete="off" :placeholder="t('Enter your Vault authentication token')" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('AppRole Role ID from Vault') }}
|
||||
{{ t('Required: Vault authentication token') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div v-if="sseKmsForm.auth_type === 'approle'" class="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="kms-role-id">{{ t('Role ID') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-role-id" v-model="sseKmsForm.vault_app_role_id" :placeholder="t('Enter AppRole Role ID')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('AppRole Role ID from Vault') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="kms-secret-id">{{ t('Secret ID') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-secret-id" v-model="sseKmsForm.vault_app_role_secret_id" type="password" autocomplete="off" :placeholder="t('Enter AppRole Secret ID')" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('AppRole Secret ID from Vault') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-secret-id">{{ t('Secret ID') }}</FieldLabel>
|
||||
<FieldLabel for="kms-mount-path">{{ t('Transit Mount Path') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-secret-id" v-model="sseKmsForm.vault_app_role_secret_id" type="password" autocomplete="off" :placeholder="t('Enter AppRole Secret ID')" />
|
||||
<Input id="kms-mount-path" v-model="sseKmsForm.mount_path" :placeholder="t('transit')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('AppRole Secret ID from Vault') }}
|
||||
{{ t('Transit engine mount path, default: transit') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-mount-path">{{ t('Transit Mount Path') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-mount-path" v-model="sseKmsForm.mount_path" :placeholder="t('transit')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Transit engine mount path, default: transit') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-kv-mount">{{ t('KV Mount Path') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-kv-mount" v-model="sseKmsForm.kv_mount" :placeholder="t('secret')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('KV storage mount path, default: secret') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-key-prefix">{{ t('Key Path Prefix') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-key-prefix" v-model="sseKmsForm.key_path_prefix" :placeholder="t('rustfs/kms/keys')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Key storage path prefix in KV store') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="kms-timeout">{{ t('Timeout (seconds)') }}</FieldLabel>
|
||||
<FieldLabel for="kms-kv-mount">{{ t('KV Mount Path') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-timeout" v-model="sseKmsForm.timeout_seconds" type="number" placeholder="30" />
|
||||
<Input id="kms-kv-mount" v-model="sseKmsForm.kv_mount" :placeholder="t('secret')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Request timeout in seconds, default: 30') }}
|
||||
{{ t('KV storage mount path, default: secret') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-retry">{{ t('Retry Attempts') }}</FieldLabel>
|
||||
<FieldLabel for="kms-key-prefix">{{ t('Key Path Prefix') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-retry" v-model="sseKmsForm.retry_attempts" type="number" placeholder="3" />
|
||||
<Input id="kms-key-prefix" v-model="sseKmsForm.key_path_prefix" :placeholder="t('rustfs/kms/keys')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Number of retry attempts, default: 3') }}
|
||||
{{ t('Key storage path prefix in KV store') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="kms-default-key">{{ t('Default Key ID') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-default-key" v-model="sseKmsForm.default_key_id" :placeholder="t('rustfs-master')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Default master key ID for SSE-KMS') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="kms-timeout">{{ t('Timeout (seconds)') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-timeout" v-model="sseKmsForm.timeout_seconds" type="number" placeholder="30" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Request timeout in seconds, default: 30') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="kms-retry">{{ t('Retry Attempts') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-retry" v-model="sseKmsForm.retry_attempts" type="number" placeholder="3" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Number of retry attempts, default: 3') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel>{{ t('Enable Cache') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<Switch v-model:checked="sseKmsForm.enable_cache" />
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Enable caching for better performance, default: true') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="kms-default-key">{{ t('Default Key ID') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-default-key" v-model="sseKmsForm.default_key_id" :placeholder="t('rustfs-master')" autocomplete="off" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Default master key ID for SSE-KMS') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field v-if="sseKmsForm.enable_cache">
|
||||
<FieldLabel for="kms-cache-ttl">{{ t('Cache TTL (seconds)') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-cache-ttl" v-model="sseKmsForm.cache_ttl_seconds" type="number" placeholder="600" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Cache time-to-live in seconds, default: 600') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel>{{ t('Enable Cache') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<Switch v-model:checked="sseKmsForm.enable_cache" />
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Enable caching for better performance, default: true') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" @click="cancelEditing">
|
||||
{{ t('Cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" :loading="saving">
|
||||
{{ t('Save Configuration') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<Field v-if="sseKmsForm.enable_cache">
|
||||
<FieldLabel for="kms-cache-ttl">{{ t('Cache TTL (seconds)') }}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input id="kms-cache-ttl" v-model="sseKmsForm.cache_ttl_seconds" type="number" placeholder="600" />
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{{ t('Cache time-to-live in seconds, default: 600') }}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" @click="cancelEditing">
|
||||
{{ t('Cancel') }}
|
||||
</Button>
|
||||
<Button type="submit" variant="default" :loading="saving">
|
||||
{{ t('Save Configuration') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- KMS 密钥列表 -->
|
||||
@@ -407,7 +392,7 @@
|
||||
<div class="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">{{
|
||||
t('Master Key (CMK)')
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="font-medium">{{ getKeyName(key) }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
@@ -473,7 +458,7 @@
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span class="text-sm font-medium text-green-700 dark:text-green-300">{{
|
||||
t('Data Key (DEK)')
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="font-medium">{{ getKeyName(key) }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
@@ -503,13 +488,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-if="kmsKeys.length === 0"
|
||||
:title="t('No KMS keys found')"
|
||||
:description="t('Create your first KMS key to get started')"
|
||||
icon="ri:key-2-line"
|
||||
class="py-12"
|
||||
>
|
||||
<EmptyState v-if="kmsKeys.length === 0" :title="t('No KMS keys found')" :description="t('Create your first KMS key to get started')" icon="ri:key-2-line" class="py-12">
|
||||
<Button class="mt-4" :disabled="!canAddKeys" @click="showCreateKeyModal = true">
|
||||
{{ t('Create First Key') }}
|
||||
</Button>
|
||||
@@ -531,27 +510,13 @@
|
||||
<CardContent class="space-y-4">
|
||||
<!-- 搜索和排序 -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput
|
||||
v-model="bucketSearchQuery"
|
||||
:placeholder="t('Search buckets...')"
|
||||
clearable
|
||||
class="flex-1"
|
||||
/>
|
||||
<Selector
|
||||
v-model="bucketSortBy"
|
||||
:options="bucketSortOptions"
|
||||
:placeholder="t('Sort by')"
|
||||
class="w-full sm:w-40"
|
||||
/>
|
||||
<SearchInput v-model="bucketSearchQuery" :placeholder="t('Search buckets...')" clearable class="flex-1" />
|
||||
<Selector v-model="bucketSortBy" :options="bucketSortOptions" :placeholder="t('Sort by')" class="w-full sm:w-40" />
|
||||
</div>
|
||||
|
||||
<!-- Bucket 列表 -->
|
||||
<div v-if="filteredBuckets.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="bucket in filteredBuckets"
|
||||
:key="bucket.name"
|
||||
class="rounded-lg border p-4"
|
||||
>
|
||||
<div v-for="bucket in filteredBuckets" :key="bucket.name" class="rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -586,13 +551,8 @@
|
||||
<Icon name="ri:lock-line" class="size-4" />
|
||||
{{ t('Configure') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="bucket.encryptionStatus === 'Enabled'"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
class="bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
@click="removeBucketEncryption(bucket)"
|
||||
>
|
||||
<Button v-if="bucket.encryptionStatus === 'Enabled'" size="sm" variant="destructive" class="bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
@click="removeBucketEncryption(bucket)">
|
||||
<Icon name="ri:lock-unlock-line" class="size-4" />
|
||||
{{ t('Remove') }}
|
||||
</Button>
|
||||
@@ -601,10 +561,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 加密详细信息 -->
|
||||
<div
|
||||
v-if="bucket.encryptionStatus === 'Enabled'"
|
||||
class="mt-3 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20"
|
||||
>
|
||||
<div v-if="bucket.encryptionStatus === 'Enabled'" class="mt-3 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20">
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
<strong>{{ t('Algorithm') }}:</strong> {{ bucket.encryptionAlgorithm }}
|
||||
@@ -616,18 +573,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!bucketListLoading && buckets.length === 0"
|
||||
class="py-8 text-center text-gray-500"
|
||||
>
|
||||
<div v-else-if="!bucketListLoading && buckets.length === 0" class="py-8 text-center text-gray-500">
|
||||
<Icon name="ri:folder-2-line" class="mb-2 mx-auto text-4xl" />
|
||||
<div>{{ t('No buckets found') }}</div>
|
||||
<div class="text-sm">{{ t('Create your first bucket to configure encryption') }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!bucketListLoading && buckets.length > 0 && filteredBuckets.length === 0"
|
||||
class="py-8 text-center text-gray-500"
|
||||
>
|
||||
<div v-else-if="!bucketListLoading && buckets.length > 0 && filteredBuckets.length === 0" class="py-8 text-center text-gray-500">
|
||||
<Icon name="ri:search-line" class="mb-2 mx-auto text-4xl" />
|
||||
<div>{{ t('No buckets match your search') }}</div>
|
||||
<div class="text-sm">{{ t('Try adjusting your search terms') }}</div>
|
||||
@@ -879,15 +830,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import EmptyState from '@/components/empty-state.vue'
|
||||
import Selector from '@/components/selector.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { useMessage } from '@/composables/ui'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Selector from '~/components/selector.vue'
|
||||
const {
|
||||
getKMSStatus,
|
||||
getConfiguration,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">{{ t('Tiers') }}</h1>
|
||||
<template #actions>
|
||||
<Button variant="secondary" @click="openNewForm">
|
||||
<Button variant="outline" @click="openNewForm">
|
||||
<Icon name="ri:add-line" class="size-4" />
|
||||
<span>{{ t('Add Tier') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<page>
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">{{ t('User Groups') }}</h1>
|
||||
<template #actions>
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search User Group')" clearable class="w-full max-w-md" />
|
||||
<Button type="button" variant="outline" :disabled="!selectedKeys.length" @click="allocationPolicy">
|
||||
<Icon class="size-4" name="ri:group-2-fill" />
|
||||
{{ t('Assign Policy') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="addUserGroup">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Add User Group') }}
|
||||
</Button>
|
||||
</template>
|
||||
</page-header>
|
||||
|
||||
<DataTable
|
||||
:table="table"
|
||||
:is-loading="loading"
|
||||
:empty-title="t('No Data')"
|
||||
:empty-description="t('Create user groups to organize permissions.')"
|
||||
table-class="min-w-full"
|
||||
/>
|
||||
<DataTablePagination :table="table" class="px-2 py-3" />
|
||||
|
||||
<user-group-edit-form ref="editItemRef" />
|
||||
<user-group-new-form ref="newItemRef" v-model:visible="newItemVisible" @search="getDataList" />
|
||||
<user-group-set-policies-mutiple
|
||||
ref="policiesRef"
|
||||
:checkedKeys="selectedKeys"
|
||||
@changePoliciesSuccess="changePoliciesSuccess"
|
||||
/>
|
||||
</page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '#components'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DataTablePagination from '@/components/data-table/data-table-pagination.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
const group = useGroups()
|
||||
|
||||
interface GroupRow {
|
||||
name: string
|
||||
}
|
||||
|
||||
const searchTerm = ref('')
|
||||
const listData = ref<GroupRow[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns: ColumnDef<GroupRow>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
checked: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
class: 'translate-y-0.5',
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
checked: row.getIsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
class: 'translate-y-0.5',
|
||||
}),
|
||||
size: 48,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => t('Name'),
|
||||
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.name),
|
||||
filterFn: 'includesString',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) =>
|
||||
h('div', { class: 'flex items-center justify-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'button',
|
||||
size: 'sm',
|
||||
variant: 'outline',
|
||||
onClick: () => openEditItem(row.original),
|
||||
},
|
||||
() => [h(Icon, { class: 'size-4', name: 'ri:edit-2-line' }), h('span', t('Edit'))]
|
||||
),
|
||||
h(
|
||||
AlertDialog,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
AlertDialogTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'button',
|
||||
size: 'sm',
|
||||
variant: 'outline',
|
||||
},
|
||||
() => [h(Icon, { class: 'size-4', name: 'ri:delete-bin-5-line' }), h('span', t('Delete'))]
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
AlertDialogContent,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(AlertDialogHeader, {}, { default: () => h(AlertDialogTitle, {}, () => t('Confirm Delete')) }),
|
||||
h(AlertDialogFooter, {}, {
|
||||
default: () => [
|
||||
h(AlertDialogCancel, {}, () => t('Cancel')),
|
||||
h(
|
||||
AlertDialogAction,
|
||||
{
|
||||
onClick: () => deleteItem(row.original),
|
||||
},
|
||||
() => t('Delete')
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
const { table } = useDataTable<GroupRow>({
|
||||
data: listData,
|
||||
columns,
|
||||
getRowId: row => row.name,
|
||||
})
|
||||
|
||||
const selectedKeys = computed(() => table.getSelectedRowModel().rows.map(row => row.original.name))
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
})
|
||||
|
||||
const getDataList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await group.listGroup()
|
||||
listData.value =
|
||||
res?.map((item: string) => ({
|
||||
name: item,
|
||||
})) || []
|
||||
} catch (error) {
|
||||
message.error(t('Failed to get data'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const newItemRef = ref()
|
||||
const newItemVisible = ref(false)
|
||||
|
||||
function addUserGroup() {
|
||||
newItemVisible.value = true
|
||||
}
|
||||
|
||||
const policiesRef = ref()
|
||||
const allocationPolicy = () => {
|
||||
policiesRef.value.openDialog()
|
||||
}
|
||||
|
||||
const changePoliciesSuccess = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const editItemRef = ref()
|
||||
function openEditItem(row: GroupRow) {
|
||||
editItemRef.value.openDialog(row)
|
||||
}
|
||||
|
||||
async function deleteItem(row: GroupRow) {
|
||||
try {
|
||||
const info = await group.getGroup(row.name)
|
||||
if (info.members.length) {
|
||||
message.error(t('Please remove members first'))
|
||||
return
|
||||
}
|
||||
await group.updateGroupMembers({
|
||||
group: row.name,
|
||||
members: info.members,
|
||||
isRemove: true,
|
||||
groupStatus: 'enabled',
|
||||
})
|
||||
message.success(t('Delete Success'))
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
}
|
||||
|
||||
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: async () => {
|
||||
if (!selectedKeys.value.length) {
|
||||
message.error(t('Please select at least one item'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await Promise.all(selectedKeys.value.map(item => group.removeGroup(item)))
|
||||
table.resetRowSelection()
|
||||
message.success(t('Delete Success'))
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(searchTerm, value => {
|
||||
table.getColumn('name')?.setFilterValue(value || undefined)
|
||||
})
|
||||
</script>
|
||||
+221
-29
@@ -2,42 +2,234 @@
|
||||
<page>
|
||||
<page-header>
|
||||
<h1 class="text-2xl font-bold">{{ t('Users') }}</h1>
|
||||
<template #actions>
|
||||
<SearchInput v-model="searchTerm" :placeholder="t('Search Access User')" clearable class="max-w-xs" />
|
||||
<Button type="button" variant="outline" :disabled="!selectedKeys.length" @click="deleteByList">
|
||||
<Icon class="size-4" name="ri:delete-bin-5-line" />
|
||||
{{ t('Delete Selected') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" :disabled="!selectedKeys.length" @click="addToGroup">
|
||||
<Icon class="size-4" name="ri:group-2-fill" />
|
||||
{{ t('Add to Group') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="addUserItem">
|
||||
<Icon class="size-4" name="ri:add-line" />
|
||||
{{ t('Add User') }}
|
||||
</Button>
|
||||
</template>
|
||||
</page-header>
|
||||
<div>
|
||||
<Tabs v-model="activeTab" class="flex flex-col gap-4">
|
||||
<TabsList class="w-full justify-start overflow-x-auto">
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:value="tab.key"
|
||||
class="capitalize"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="users" class="mt-0">
|
||||
<users-tabs-user />
|
||||
</TabsContent>
|
||||
<TabsContent value="userGroup" class="mt-0">
|
||||
<users-tabs-group />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div class="space-y-4">
|
||||
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Data')" :empty-description="t('Create a user to get started.')" table-class="min-w-full" />
|
||||
<DataTablePagination :table="table" />
|
||||
|
||||
<user-new-form ref="newItemRef" @search="getDataList" />
|
||||
<user-edit-form ref="editItemRef" @search="getDataList" />
|
||||
</div>
|
||||
</page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { computed, ref } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '#components'
|
||||
import DataTablePagination from '@/components/data-table/data-table-pagination.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { useDataTable } from '@/components/data-table/useDataTable'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref('users')
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'users', label: t('Users') },
|
||||
{ key: 'userGroup', label: t('User Groups') },
|
||||
])
|
||||
const { listUsers, deleteUser } = useUsers()
|
||||
const dialog = useDialog()
|
||||
const message = useMessage()
|
||||
|
||||
interface UserRow {
|
||||
accessKey: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const searchTerm = ref('')
|
||||
const loading = ref(false)
|
||||
const listData = ref<UserRow[]>([])
|
||||
|
||||
const newItemRef = ref()
|
||||
const editItemRef = ref()
|
||||
|
||||
async function deleteItem(row: UserRow) {
|
||||
try {
|
||||
await deleteUser(row.accessKey)
|
||||
message.success(t('Delete Success'))
|
||||
table.resetRowSelection()
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditItem(row: UserRow) {
|
||||
editItemRef.value?.openDialog(row)
|
||||
}
|
||||
|
||||
function addUserItem() {
|
||||
newItemRef.value?.openDialog()
|
||||
}
|
||||
|
||||
const addToGroup = () => { }
|
||||
|
||||
async function getDataList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listUsers()
|
||||
listData.value = Object.entries(res).map(([username, info]) => ({
|
||||
accessKey: username,
|
||||
...(typeof info === 'object' ? info : {}),
|
||||
}))
|
||||
} catch (error) {
|
||||
message.error(t('Failed to get data'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
checked: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
class: 'translate-y-0.5',
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
checked: row.getIsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
class: 'translate-y-0.5',
|
||||
}),
|
||||
size: 48,
|
||||
},
|
||||
{
|
||||
accessorKey: 'accessKey',
|
||||
header: () => t('Name'),
|
||||
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.accessKey),
|
||||
filterFn: 'includesString',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) =>
|
||||
h('div', { class: 'flex items-center justify-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'button',
|
||||
size: 'sm',
|
||||
variant: 'outline',
|
||||
onClick: () => openEditItem(row.original),
|
||||
},
|
||||
() => [h(Icon, { class: 'size-4', name: 'ri:edit-2-line' }), h('span', t('Edit'))]
|
||||
),
|
||||
h(
|
||||
AlertDialog,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
AlertDialogTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'button',
|
||||
size: 'sm',
|
||||
variant: 'outline',
|
||||
},
|
||||
() => [h(Icon, { class: 'size-4', name: 'ri:delete-bin-5-line' }), h('span', t('Delete'))]
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
AlertDialogContent,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(AlertDialogHeader, {}, { default: () => h(AlertDialogTitle, {}, () => t('Confirm Delete')) }),
|
||||
h(AlertDialogFooter, {}, {
|
||||
default: () => [
|
||||
h(AlertDialogCancel, {}, () => t('Cancel')),
|
||||
h(
|
||||
AlertDialogAction,
|
||||
{
|
||||
onClick: () => deleteItem(row.original),
|
||||
},
|
||||
() => t('Delete')
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
const { table } = useDataTable<UserRow>({
|
||||
data: listData,
|
||||
columns,
|
||||
getRowId: row => row.accessKey,
|
||||
})
|
||||
|
||||
const selectedKeys = computed(() => table.getSelectedRowModel().rows.map(row => row.original.accessKey))
|
||||
|
||||
function deleteByList() {
|
||||
if (!selectedKeys.value.length) return
|
||||
dialog.error({
|
||||
title: t('Warning'),
|
||||
content: t('Are you sure you want to delete all selected users?'),
|
||||
positiveText: t('Confirm'),
|
||||
negativeText: t('Cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await Promise.all(selectedKeys.value.map(item => deleteUser(item)))
|
||||
message.success(t('Delete Success'))
|
||||
table.resetRowSelection()
|
||||
await getDataList()
|
||||
} catch (error) {
|
||||
message.error(t('Delete Failed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(searchTerm, value => {
|
||||
table.getColumn('accessKey')?.setFilterValue(value || undefined)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
Reference in New Issue
Block a user