fix: tables & forms

This commit is contained in:
overtrue
2025-10-30 21:22:58 +08:00
parent de7a1b13b4
commit c3bb12bc55
55 changed files with 1164 additions and 1678 deletions
+1
View File
@@ -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/`.
+6 -27
View File
@@ -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 }}
+5 -10
View File
@@ -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" />
+4 -14
View File
@@ -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()
+1 -1
View File
@@ -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" />
+4 -10
View File
@@ -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>
+3 -3
View File
@@ -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'
+3 -11
View File
@@ -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>
+2 -16
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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()
+4 -10
View File
@@ -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()
+3 -3
View File
@@ -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>
+4 -8
View File
@@ -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<{
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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[]
+1 -1
View File
@@ -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<{
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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
+3 -8
View File
@@ -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>
+8 -28
View File
@@ -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()
+3 -8
View File
@@ -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()
+1 -1
View File
@@ -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,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'
-245
View File
@@ -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>
-245
View File
@@ -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>
+5
View File
@@ -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',
-75
View File
@@ -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.
-25
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
-166
View File
@@ -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>
+3 -9
View File
@@ -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()
+1 -1
View File
@@ -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>
+3 -9
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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>
+259
View File
@@ -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
View File
@@ -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>