refactor: 90%

This commit is contained in:
overtrue
2025-10-28 22:46:23 +08:00
parent 2a7c74d62e
commit 4ef8fa6347
100 changed files with 5436 additions and 4824 deletions
+36
View File
@@ -0,0 +1,36 @@
# Repository Guidelines
## Project Structure & Module Organization
- Core application lives under `pages/`, with supporting UI atoms in `components/`.
- Shared state and utilities are in `store/`, `composables/`, and `lib/`.
- Configuration lives in `app.config.ts`, `nuxt.config.ts`, and `config/`.
- Tests belong in `tests/`, and static assets in `public/` or `assets/`.
## Build, Test, and Development Commands
- `pnpm dev` start the Nuxt development server with hot reload.
- `pnpm build` create a production build.
- `pnpm preview` run the production bundle locally.
- `pnpm test:run` execute the Vitest suite once.
- `pnpm vue-tsc --noEmit` perform a strict type check.
- `pnpm lint` run `vue-tsc` and Prettier (format check only).
## Coding Style & Naming Conventions
- Use Prettier defaults (see `.prettierrc.ts`); run `pnpm lint` or `pnpm lint:fix`.
- 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.
## Testing Guidelines
- Vitest is the primary framework; add new suites under `tests/`.
- Name files `*.spec.ts` or `*.test.ts` and mirror source structure.
- Keep tests deterministic; mock network calls through provided composables.
- Run `pnpm test:run` before submitting major changes.
## Commit & Pull Request Guidelines
- Follow conventional, action-oriented commit subjects (e.g. `feat: add bucket selector`).
- Each pull request should include: a concise summary, linked issue or task, screenshots for UI work, and testing notes (`pnpm test:run`, `pnpm vue-tsc`, etc.).
- Keep PRs scoped; large refactors should be coordinated in advance.
## UI Theme Overrides
- Apply visual tweaks (e.g., removing shadows, altering colors) at usage sites via classes such as `class="shadow-none"`.
- When extending shadcn components, create wrapper utilities (e.g., `BucketSelector.vue`) instead of forking primitives.
+3 -1
View File
@@ -158,6 +158,8 @@ npm run preview
### Code Quality
- Component files use **kebab-case** (e.g., `search-input.vue`, `action-bar.vue`).
We maintain high code quality standards with:
- **TypeScript**: Full type safety and better developer experience
@@ -204,7 +206,7 @@ Add new languages by:
### Component Library
Built on shadcn-vue primitives with custom wrappers in `components/ui/` and `components/app`. Extend the design system by:
Built on shadcn-vue primitives with custom wrappers in `components/ui/` and the root `components/` directory (e.g. `modal.vue`, `drawer.vue`, `selector.vue`). Extend the design system by:
- Adding new components to `components/ui/`
- Following established naming conventions
-5
View File
@@ -9,17 +9,12 @@
<NuxtPage />
</NuxtLayout>
</ProvidersAppUiProvider>
<Toaster :theme="colorMode as any || 'system'" />
</Body>
</template>
<script lang="ts" setup>
import { useColorMode } from '@vueuse/core'
import { computed } from 'vue'
import { Toaster } from '@/components/ui/sonner'
const activeTheme = useCookie<string>('active_theme', { readonly: true })
const isScaled = computed(() => !!activeTheme.value?.endsWith('-scaled'))
const colorMode = useColorMode()
</script>
+1 -1
View File
@@ -28,7 +28,7 @@
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.25rem;
--radius: 0.3rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
+45 -39
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { Spinner } from '@/components/ui/spinner'
import { AppModal } from '@/components/app'
import { Label } from '@/components/ui/label'
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'
@@ -101,52 +101,58 @@ async function submitForm() {
</script>
<template>
<AppModal
<Modal
v-model="modalVisible"
:title="t('Change current account password')"
size="md"
:close-on-backdrop="false"
>
<div class="space-y-4">
<div class="grid gap-2">
<Label for="password-current">{{ t('Current Password') }}</Label>
<Input
id="password-current"
v-model="formModel.current_secret_key"
type="password"
autocomplete="off"
/>
<p v-if="errors.current_secret_key" class="text-sm text-destructive">
<Field>
<FieldLabel for="password-current">{{ t('Current Password') }}</FieldLabel>
<FieldContent>
<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 }}
</p>
</div>
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="password-new">{{ t('New Password') }}</Label>
<Input
id="password-new"
v-model="formModel.new_secret_key"
type="password"
autocomplete="off"
/>
<p v-if="errors.new_secret_key" class="text-sm text-destructive">
<Field>
<FieldLabel for="password-new">{{ t('New Password') }}</FieldLabel>
<FieldContent>
<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 }}
</p>
</div>
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="password-new-confirm">{{ t('Confirm New Password') }}</Label>
<Input
id="password-new-confirm"
v-model="formModel.re_new_secret_key"
type="password"
autocomplete="off"
:disabled="!formModel.new_secret_key"
/>
<p v-if="errors.re_new_secret_key" class="text-sm text-destructive">
<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"
/>
</FieldContent>
<FieldDescription v-if="errors.re_new_secret_key" class="text-destructive">
{{ errors.re_new_secret_key }}
</p>
</div>
</FieldDescription>
</Field>
</div>
<template #footer>
@@ -160,5 +166,5 @@ async function submitForm() {
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
+42 -30
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { Spinner } from '@/components/ui/spinner'
import { AppModal } from '@/components/app'
import AppDateTimePicker from '@/components/app/AppDateTimePicker.vue'
import { Label } from '@/components/ui/label'
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'
@@ -93,42 +93,54 @@ async function submitForm() {
</script>
<template>
<AppModal
<Modal
v-model="visible"
:title="t('Edit Key')"
size="lg"
:close-on-backdrop="false"
>
<div class="space-y-4">
<div class="grid gap-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="grid gap-2">
<Label>{{ t('Policy') }}</Label>
<json-editor v-model="formModel.policy" />
</div>
<Field>
<FieldLabel>{{ t('Policy') }}</FieldLabel>
<FieldContent>
<json-editor v-model="formModel.policy" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Expiry') }}</Label>
<AppDateTimePicker v-model="formModel.expiry" :min="minExpiry" />
</div>
<Field>
<FieldLabel>{{ t('Expiry') }}</FieldLabel>
<FieldContent>
<DateTimePicker v-model="formModel.expiry" :min="minExpiry" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Name') }}</Label>
<Input v-model="formModel.name" />
</div>
<Field>
<FieldLabel>{{ t('Name') }}</FieldLabel>
<FieldContent>
<Input v-model="formModel.name" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Description') }}</Label>
<Textarea v-model="formModel.description" :rows="3" />
</div>
<Field>
<FieldLabel>{{ t('Description') }}</FieldLabel>
<FieldContent>
<Textarea v-model="formModel.description" :rows="3" />
</FieldContent>
</Field>
<div class="flex items-center justify-between rounded-md border p-3">
<span class="text-sm font-medium">{{ t('Status') }}</span>
<Switch v-model:checked="statusBoolean" />
</div>
<Field orientation="responsive" class="items-center rounded-md border p-3">
<FieldLabel class="text-sm font-medium">{{ t('Status') }}</FieldLabel>
<FieldContent class="flex justify-end">
<Switch v-model:checked="statusBoolean" />
</FieldContent>
</Field>
</div>
<template #footer>
@@ -142,5 +154,5 @@ async function submitForm() {
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
+65 -45
View File
@@ -1,59 +1,79 @@
<template>
<AppModal
<Modal
v-model="modalVisible"
:title="t('Create Key')"
size="lg"
:close-on-backdrop="false"
>
<div class="space-y-4">
<div class="grid gap-2">
<Label for="create-access-key">{{ t('Access Key') }}</Label>
<Input id="create-access-key" v-model="formModel.accessKey" autocomplete="off" />
<p v-if="errors.accessKey" class="text-sm text-destructive">{{ errors.accessKey }}</p>
</div>
<Field>
<FieldLabel for="create-access-key">{{ t('Access Key') }}</FieldLabel>
<FieldContent>
<Input id="create-access-key" v-model="formModel.accessKey" autocomplete="off" />
</FieldContent>
<FieldDescription v-if="errors.accessKey" class="text-destructive">
{{ errors.accessKey }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="create-secret-key">{{ t('Secret Key') }}</Label>
<Input id="create-secret-key" v-model="formModel.secretKey" type="password" autocomplete="off" />
<p v-if="errors.secretKey" class="text-sm text-destructive">{{ errors.secretKey }}</p>
</div>
<Field>
<FieldLabel for="create-secret-key">{{ t('Secret Key') }}</FieldLabel>
<FieldContent>
<Input id="create-secret-key" v-model="formModel.secretKey" type="password" autocomplete="off" />
</FieldContent>
<FieldDescription v-if="errors.secretKey" class="text-destructive">
{{ errors.secretKey }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="create-expiry">{{ t('Expiry') }}</Label>
<AppDateTimePicker
id="create-expiry"
v-model="formModel.expiry"
:min="minExpiry"
:placeholder="t('Please select expiry date')"
/>
<p v-if="errors.expiry" class="text-sm text-destructive">{{ errors.expiry }}</p>
</div>
<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')"
/>
</FieldContent>
<FieldDescription v-if="errors.expiry" class="text-destructive">
{{ errors.expiry }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="create-name">{{ t('Name') }}</Label>
<Input id="create-name" v-model="formModel.name" autocomplete="off" />
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<Field>
<FieldLabel for="create-name">{{ t('Name') }}</FieldLabel>
<FieldContent>
<Input id="create-name" v-model="formModel.name" autocomplete="off" />
</FieldContent>
<FieldDescription v-if="errors.name" class="text-destructive">
{{ errors.name }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="create-description">{{ t('Description') }}</Label>
<Textarea id="create-description" v-model="formModel.description" :rows="3" />
</div>
<Field>
<FieldLabel for="create-description">{{ t('Description') }}</FieldLabel>
<FieldContent>
<Textarea id="create-description" v-model="formModel.description" :rows="3" />
</FieldContent>
</Field>
<div class="flex items-start justify-between gap-3 rounded-md border p-3">
<div>
<p class="text-sm font-medium">{{ t('Use main account policy') }}</p>
<Field orientation="responsive" class="items-start gap-3 rounded-md border p-3">
<FieldLabel class="text-sm font-medium">{{ t('Use main account policy') }}</FieldLabel>
<FieldContent class="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<p class="text-xs text-muted-foreground">
{{ t('Automatically inherit the main account policy when enabled.') }}
</p>
</div>
<Switch v-model:checked="formModel.impliedPolicy" />
</div>
<Switch v-model:checked="formModel.impliedPolicy" />
</FieldContent>
</Field>
<div v-if="!formModel.impliedPolicy" class="grid gap-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>
<template #footer>
@@ -67,17 +87,17 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { Spinner } from '@/components/ui/spinner'
import { AppModal } from '@/components/app'
import AppDateTimePicker from '@/components/app/AppDateTimePicker.vue'
import { Label } from '@/components/ui/label'
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'
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-wrap items-center justify-end gap-2', props.class)">
<slot />
</div>
</template>
+2 -2
View File
@@ -84,7 +84,7 @@ const getLabel = (item: NavItem) => t(item.label)
<span>{{ brandInitial }}</span>
</div>
<div v-if="!isCollapsed" class="flex min-w-0 flex-col px-3 py-4">
<img src="~/assets/logo.svg" alt="RustFS" class="h-6" />
<img src="~/assets/logo.svg" alt="RustFS" class="h-4" />
</div>
</NuxtLink>
</SidebarHeader>
@@ -92,7 +92,7 @@ const getLabel = (item: NavItem) => t(item.label)
<SidebarContent>
<ScrollArea class="flex-1 pr-1">
<div class="flex flex-col gap-4">
<SidebarGroup v-for="(group, groupIndex) in navGroups" :key="groupIndex" class="gap-4">
<SidebarGroup v-for="(group, groupIndex) in navGroups" :key="groupIndex" class="gap-4 py-0">
<SidebarGroupContent>
<SidebarMenu>
<template v-for="item in group" :key="item.label">
-48
View File
@@ -1,48 +0,0 @@
<script setup lang="ts">
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
const props = withDefaults(
defineProps<{
title?: string
description?: string
class?: HTMLAttributes['class']
contentClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
padded?: boolean
}>(),
{
title: undefined,
description: undefined,
class: undefined,
contentClass: undefined,
footerClass: undefined,
padded: true,
}
)
</script>
<template>
<Card :class="props.class">
<CardHeader v-if="title || $slots.header">
<slot name="header">
<CardTitle v-if="title">{{ title }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</slot>
</CardHeader>
<CardContent :class="cn(padded && 'space-y-4', contentClass)">
<slot />
</CardContent>
<CardFooter v-if="$slots.footer" :class="footerClass">
<slot name="footer" />
</CardFooter>
</Card>
</template>
-44
View File
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
const props = withDefaults(
defineProps<{
label?: string
description?: string
disabled?: boolean
class?: HTMLAttributes['class']
}>(),
{
label: undefined,
description: undefined,
disabled: false,
class: undefined,
}
)
const modelValue = defineModel<boolean>({ default: false })
const handleCheckedChange = (value: boolean | 'indeterminate') => {
modelValue.value = value === true
}
</script>
<template>
<label :class="cn('flex items-start gap-3', props.class)">
<Checkbox
:checked="modelValue"
:disabled="disabled"
class="mt-1"
@update:checked="handleCheckedChange"
/>
<span class="flex flex-col gap-1">
<span v-if="label" class="text-sm font-medium leading-tight">{{ label }}</span>
<span v-else class="text-sm font-medium leading-tight">
<slot />
</span>
<span v-if="description" class="text-xs text-muted-foreground">{{ description }}</span>
</span>
</label>
</template>
-63
View File
@@ -1,63 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
import AppCheckbox from './AppCheckbox.vue'
type OptionValue = string | number | boolean
export interface CheckboxOption {
label: string
value: OptionValue
description?: string
disabled?: boolean
}
const props = withDefaults(
defineProps<{
options: CheckboxOption[]
direction?: 'vertical' | 'horizontal'
class?: HTMLAttributes['class']
}>(),
{
direction: 'vertical',
class: undefined,
}
)
const modelValue = defineModel<OptionValue[]>({
default: [],
})
const isChecked = (value: OptionValue) => modelValue.value.includes(value)
const handleChange = (value: OptionValue, checked: boolean) => {
const next = new Set(modelValue.value)
if (checked) {
next.add(value)
} else {
next.delete(value)
}
modelValue.value = Array.from(next)
}
</script>
<template>
<div
:class="cn(
'gap-3',
direction === 'horizontal' ? 'flex flex-wrap items-center' : 'flex flex-col',
props.class
)"
>
<AppCheckbox
v-for="option in options"
:key="String(option.value)"
:label="option.label"
:description="option.description"
:disabled="option.disabled"
:class="direction === 'horizontal' ? 'flex-row items-center' : ''"
:model-value="isChecked(option.value)"
@update:model-value="value => handleChange(option.value, value)"
/>
</div>
</template>
-67
View File
@@ -1,67 +0,0 @@
<script setup lang="ts">
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
type OptionValue = string | number
export interface RadioOption {
label: string
value: OptionValue
description?: string
disabled?: boolean
}
const props = withDefaults(
defineProps<{
options: RadioOption[]
class?: HTMLAttributes['class']
itemClass?: HTMLAttributes['class']
}>(),
{
class: undefined,
itemClass: undefined,
}
)
const modelValue = defineModel<OptionValue | null>({
default: null,
})
const internalValue = computed<string | undefined>({
get: () => {
const value = modelValue.value
if (value === null || value === undefined) {
return undefined
}
return String(value)
},
set: value => {
if (value === undefined) {
modelValue.value = null
return
}
const option = props.options.find(option => String(option.value) === value)
modelValue.value = option ? option.value : (value as OptionValue)
},
})
</script>
<template>
<RadioGroup v-model="internalValue" :class="props.class">
<label
v-for="option in options"
:key="String(option.value)"
:class="cn('flex items-start gap-3 rounded-md border border-border/50 p-3', itemClass, option.disabled && 'opacity-60')"
>
<RadioGroupItem :value="String(option.value)" :disabled="option.disabled" 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>
</label>
</RadioGroup>
</template>
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { Loader2Icon } from 'lucide-vue-next'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
class?: HTMLAttributes['class']
}>(),
{
size: 'md',
}
)
const sizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'size-3'
case 'lg':
return 'size-6'
case 'xl':
return 'size-8'
default:
return 'size-4'
}
})
</script>
<template>
<Loader2Icon
role="status"
aria-label="Loading"
:class="cn('animate-spin text-muted-foreground', sizeClass, props.class)"
/>
</template>
-31
View File
@@ -1,31 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
type TagTone = 'default' | 'info' | 'success' | 'warning' | 'danger'
const toneClass: Record<TagTone, string> = {
default: 'bg-muted text-muted-foreground',
info: 'bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-200',
success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-200',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200',
danger: 'bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-200',
}
const props = withDefaults(
defineProps<{
tone?: TagTone
class?: HTMLAttributes['class']
}>(),
{
tone: 'default',
class: undefined,
}
)
</script>
<template>
<Badge variant="outline" :class="cn('lowercase border-none', toneClass[tone], props.class)">
<slot />
</Badge>
</template>
-12
View File
@@ -1,12 +0,0 @@
export { default as AppCard } from './AppCard.vue'
export { default as AppCheckbox } from './AppCheckbox.vue'
export { default as AppCheckboxGroup } from './AppCheckboxGroup.vue'
export { default as AppDateTimePicker } from './AppDateTimePicker.vue'
export { default as AppDrawer } from './AppDrawer.vue'
export { default as AppEmpty } from './AppEmpty.vue'
export { default as AppModal } from './AppModal.vue'
export { default as AppRadioGroup } from './AppRadioGroup.vue'
export { default as AppSelect } from './AppSelect.vue'
export { default as AppSpinner } from './AppSpinner.vue'
export { default as AppTag } from './AppTag.vue'
export { default as AppUploadZone } from './AppUploadZone.vue'
+15 -26
View File
@@ -1,47 +1,36 @@
<template>
<div
class="w-full bg-gray-50 dark:bg-black h-full p-16 flex flex-col justify-center gap-8 overflow-hidden relative border-r dark:border-none"
>
<div class="w-full bg-gray-50 dark:bg-black h-full p-16 flex flex-col justify-center gap-8 overflow-hidden relative border-r dark:border-none">
<div class="max-w-7xl flex flex-col z-10">
<img src="~/assets/logo.svg" class="max-w-28" alt="" />
<div class="text-4xl my-6 font-semibold text-primary! px-0">
<span :class="{ 'pr-1': locale !== 'zh' }">{{ t('Rust-based') }} </span>
<FlipWords
:words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]"
:duration="3000"
class="text-4xl font-semibold text-primary! px-0"
/>
<FlipWords :words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]" :duration="3000" class="text-4xl font-semibold text-primary! px-0" />
<div class="text-muted-foreground mt-2">
{{ t('Reliable distributed file system') }}
</div>
</div>
</div>
<a
href="https://www.rustfs.com"
class="z-10 text-primary-500 inline-flex w-max items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500"
>
<a href="https://www.rustfs.com" class="z-10 text-primary-500 inline-flex w-max items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500">
<span>{{ t('Visit website') }}</span>
<Icon name="ri:arrow-right-long-fill" class="mr-2" />
</a>
<div class="h-full inset-0 absolute z-0">
<Ripple
class="bg-white/5 -mb-[100vh] h-full w-full -mr-[50vw] mask-[linear-gradient(to_bottom,white,transparent)]"
circle-class="border-[hsl(var(--primary))] bg-[#0000]/25 dark:bg-white/25 rounded-full"
/>
<Ripple class="bg-white/5 -mb-[100vh] h-full w-full -mr-[50vw] mask-[linear-gradient(to_bottom,white,transparent)]"
circle-class="border-[hsl(var(--primary))] bg-[#0000]/25 dark:bg-white/25 rounded-full" />
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import FlipWords from '~/components/ui/flip-words/FlipWords.vue';
import Ripple from '~/components/ui/ripple/Ripple.vue';
import FlipWords from '@/components/ui/flip-words/FlipWords.vue'
import Ripple from '@/components/ui/ripple/Ripple.vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n();
const { t, locale } = useI18n()
</script>
+13 -26
View File
@@ -1,47 +1,34 @@
<template>
<div
class="w-full bg-gray-50 dark:bg-black h-full p-16 flex flex-col justify-center gap-8 overflow-hidden relative border-r dark:border-none"
>
<div class="w-full bg-gray-50 dark:bg-black h-full p-16 flex flex-col justify-center gap-8 overflow-hidden relative border-r dark:border-none">
<div class="max-w-7xl flex flex-col z-10">
<img src="~/assets/logo.svg" class="max-w-28" alt="" />
<div class="text-4xl my-6 font-semibold text-primary! px-0">
<span :class="{ 'pr-1': locale !== 'zh' }">{{ t('Rust-based') }} </span>
<FlipWords
:words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]"
:duration="3000"
class="text-4xl font-semibold text-primary! px-0"
/>
<FlipWords :words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]" :duration="3000" class="text-4xl font-semibold text-primary! px-0" />
<div class="text-muted-foreground mt-2">
{{ t('Reliable distributed file system') }}
</div>
</div>
</div>
<a
href="https://www.rustfs.com"
class="z-10 text-primary-500 inline-flex w-max items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500"
>
<a href="https://www.rustfs.com" class="z-10 text-primary-500 inline-flex w-max items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500">
<span>{{ t('Visit website') }}</span>
<Icon name="ri:arrow-right-long-fill" class="mr-2" />
</a>
<div class="h-full inset-0 absolute z-0">
<img
src="~/assets/backgrounds/ttten.svg"
class="absolute h-full w-full inset-0 z-0 opacity-45 object-cover"
alt=""
/>
<img src="~/assets/backgrounds/ttten.svg" class="absolute h-full w-full inset-0 z-0 opacity-45 object-cover" alt="" />
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import FlipWords from '~/components/ui/flip-words/FlipWords.vue';
import FlipWords from '@/components/ui/flip-words/FlipWords.vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n();
const { t, locale } = useI18n()
</script>
+16 -28
View File
@@ -4,32 +4,20 @@
<img src="~/assets/logo.svg" class="max-w-28" alt="" />
<div class="text-4xl my-6 font-semibold text-primary! px-0">
<span :class="{ 'pr-1': locale !== 'zh' }">{{ t('Rust-based') }} </span>
<FlipWords
:words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]"
:duration="3000"
class="text-4xl font-semibold text-primary! px-0"
/>
<FlipWords :words="[
t('High Performance'),
t('Infinite Scaling'),
t('Secure & Reliable'),
t('Multi-Cloud Storage'),
t('S3 Compatible'),
]" :duration="3000" class="text-4xl font-semibold text-primary! px-0" />
<div class="text-muted-foreground mt-2">
{{ t('Reliable distributed file system') }}
</div>
</div>
</div>
<WorldMap
class="absolute inset-0"
:dots="dots"
:map-color="isDark ? '#FFFFFF40' : '#00000040'"
:map-bg-color="isDark ? 'black' : '#f9fafb'"
/>
<a
href=" https://www.rustfs.com"
class="text-primary-500 inline-flex w-min items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500"
>
<WorldMap class="absolute inset-0" :dots="dots" :map-color="isDark ? '#FFFFFF40' : '#00000040'" :map-bg-color="isDark ? 'black' : '#f9fafb'" />
<a href=" https://www.rustfs.com" class="text-primary-500 inline-flex w-min items-center gap-2 leading-none p-2 px-5 border rounded-full border-blue-500">
<span>{{ t('Visit website') }}</span>
<Icon name="ri:arrow-right-long-fill" class="mr-2" />
</a>
@@ -37,11 +25,11 @@
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import FlipWords from '~/components/ui/flip-words/FlipWords.vue';
import WorldMap from '~/components/ui/world-map/WorldMap.vue';
import FlipWords from '@/components/ui/flip-words/FlipWords.vue'
import WorldMap from '@/components/ui/world-map/WorldMap.vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n();
const { t, locale } = useI18n()
const dots = [
{
@@ -74,8 +62,8 @@ const dots = [
start: { lat: 28.6139, lng: 77.209 }, // New Delhi
end: { lat: -1.2921, lng: 36.8219 }, // Nairobi
},
];
]
const mode = useColorMode();
const isDark = computed(() => mode.value == 'dark');
const mode = useColorMode()
const isDark = computed(() => mode.value == 'dark')
</script>
+74
View File
@@ -0,0 +1,74 @@
<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'
defineOptions({ inheritAttrs: false })
const modelValue = defineModel<SelectOption['value'] | null>({
default: null,
})
const props = withDefaults(
defineProps<{
options: SelectOption[]
label?: string
placeholder?: string
disabled?: boolean
description?: string
layout?: 'inline' | 'stacked'
hideLabel?: boolean
class?: HTMLAttributes['class']
selectorClass?: HTMLAttributes['class']
loading?: boolean
emptyMessage?: string
}>(),
{
label: 'Bucket',
placeholder: 'Please select bucket',
disabled: false,
description: undefined,
layout: 'inline',
hideLabel: false,
class: undefined,
selectorClass: undefined,
loading: false,
emptyMessage: undefined,
}
)
const attrs = useAttrs()
const containerClasses = computed(() =>
props.layout === 'inline' ? 'flex items-center gap-3' : 'flex flex-col gap-2'
)
const controlWrapperClasses = computed(() =>
props.layout === 'inline' ? 'flex flex-col gap-1 min-w-[220px]' : 'flex flex-col gap-1'
)
</script>
<template>
<div v-bind="attrs" :class="cn(containerClasses, props.class)">
<Label v-if="!props.hideLabel" class="text-sm font-medium text-muted-foreground">
{{ 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)"
/>
<p v-if="props.description" class="text-xs text-muted-foreground">
{{ props.description }}
</p>
</div>
<Spinner v-if="props.loading && props.layout === 'inline'" class="size-4 text-muted-foreground" />
</div>
</template>
+209 -188
View File
@@ -1,138 +1,146 @@
<template>
<AppDrawer v-model="visible" :title="drawerTitle" size="xl">
<Drawer v-model="visible" :title="drawerTitle" size="xl">
<div class="space-y-6">
<AppCard padded class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">{{ t('Access Policy') }}</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="editPolicy">
<Icon name="ri:edit-2-line" class="mr-2 size-4" />
<span>{{ t('Edit') }}</span>
</Button>
</div>
<p class="text-sm text-muted-foreground">{{ currentPolicyLabel }}</p>
</AppCard>
<ItemGroup class="space-y-4">
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader>
<ItemTitle>{{ t('Access Policy') }}</ItemTitle>
<ItemActions>
<Button variant="outline" size="sm" class="shrink-0" @click="editPolicy">
<Icon name="ri:edit-2-line" class="mr-2 size-4" />
<span>{{ t('Edit') }}</span>
</Button>
</ItemActions>
</ItemHeader>
<ItemContent>
<ItemDescription class="text-sm">{{ currentPolicyLabel }}</ItemDescription>
</ItemContent>
</Item>
<AppCard padded class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">{{ t('Encryption') }}</p>
<p class="text-xs text-muted-foreground">{{ encryptionLabel }}</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="editEncrypt">
<Icon name="ri:edit-2-line" class="mr-2 size-4" />
<span>{{ t('Edit') }}</span>
</Button>
</div>
</AppCard>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader class="items-start">
<div class="flex flex-col gap-1">
<ItemTitle>{{ t('Encryption') }}</ItemTitle>
<ItemDescription class="text-xs text-muted-foreground">
{{ encryptionLabel }}
</ItemDescription>
</div>
<ItemActions>
<Button variant="outline" size="sm" class="shrink-0" @click="editEncrypt">
<Icon name="ri:edit-2-line" class="mr-2 size-4" />
<span>{{ t('Edit') }}</span>
</Button>
</ItemActions>
</ItemHeader>
</Item>
<AppCard padded class="space-y-3">
<div class="flex items-start justify-between gap-3">
<p class="text-sm font-medium text-foreground">{{ t('Tag') }}</p>
<Button variant="outline" size="sm" class="shrink-0" @click="addTag">
<Icon name="ri:add-line" class="mr-2 size-4" />
<span>{{ t('Add') }}</span>
</Button>
</div>
<div v-if="tags.length" class="flex flex-wrap gap-2">
<div
v-for="(tag, index) in tags"
:key="`${tag.Key}-${index}`"
class="flex items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs"
>
<button type="button" class="text-left" @click="editTag(index)">
{{ tag.Key }}:{{ tag.Value }}
</button>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
@click.stop="handleDeleteTag(index)"
>
<Icon name="ri:close-line" class="size-3.5" />
</Button>
</div>
</div>
<p v-else class="text-sm text-muted-foreground">
{{ t('No Data') }}
</p>
</AppCard>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader>
<ItemTitle>{{ t('Tag') }}</ItemTitle>
<ItemActions>
<Button variant="outline" size="sm" class="shrink-0" @click="addTag">
<Icon name="ri:add-line" class="mr-2 size-4" />
<span>{{ t('Add') }}</span>
</Button>
</ItemActions>
</ItemHeader>
<ItemContent>
<div v-if="tags.length" class="flex flex-wrap gap-2">
<div v-for="(tag, index) in tags" :key="`${tag.Key}-${index}`" class="flex items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs">
<button type="button" class="text-left" @click="editTag(index)">
{{ tag.Key }}:{{ tag.Value }}
</button>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click.stop="handleDeleteTag(index)">
<Icon name="ri:close-line" class="size-3.5" />
</Button>
</div>
</div>
<ItemDescription v-else class="text-sm">
{{ t('No Data') }}
</ItemDescription>
</ItemContent>
</Item>
<AppCard padded class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-foreground">{{ t('Object Lock') }}</p>
<AppSpinner v-if="objectLockLoading" size="sm" />
</div>
<Switch v-model:checked="lockStatus" disabled />
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-foreground">{{ t('Version Control') }}</p>
<AppSpinner v-if="statusLoading" size="sm" />
</div>
<Switch
:checked="versioningStatus === 'Enabled'"
:disabled="lockStatus || statusLoading"
@update:checked="value => handleVersionToggle(value)"
/>
</div>
</AppCard>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader>
<ItemTitle>{{ t('Object Lock') }}</ItemTitle>
<ItemActions>
<Spinner v-if="objectLockLoading" class="size-3 text-muted-foreground" />
</ItemActions>
</ItemHeader>
<ItemContent class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-foreground">{{ t('Object Lock') }}</p>
<Switch v-model:checked="lockStatus" disabled />
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-foreground">{{ t('Version Control') }}</p>
<Spinner v-if="statusLoading" class="size-3 text-muted-foreground" />
</div>
<Switch :checked="versioningStatus === 'Enabled'" :disabled="lockStatus || statusLoading" @update:checked="handleVersionToggle" />
</div>
</ItemContent>
</Item>
<AppCard padded class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('Retention') }}</p>
<p class="text-xs text-muted-foreground">
{{ retentionEnabled ? t('Enabled') : t('Disabled') }}
</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="editRetention">
<span>{{ t('Edit') }}</span>
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Mode') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionMode ? t(retentionFormValue.retentionMode) : '-' }}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Unit') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionUnit ? t(retentionFormValue.retentionUnit) : '-' }}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Period') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionPeriod ?? '-' }}
</p>
</div>
</div>
</AppCard>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader class="items-start">
<div>
<ItemTitle>{{ t('Retention') }}</ItemTitle>
<ItemDescription class="text-xs text-muted-foreground">
{{ retentionEnabled ? t('Enabled') : t('Disabled') }}
</ItemDescription>
</div>
<ItemActions>
<Button variant="outline" size="sm" class="shrink-0" @click="editRetention">
<span>{{ t('Edit') }}</span>
</Button>
</ItemActions>
</ItemHeader>
<ItemContent>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Mode') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionMode ? t(retentionFormValue.retentionMode) : '-' }}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Unit') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionUnit ? t(retentionFormValue.retentionUnit) : '-' }}
</p>
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('Retention Period') }}</p>
<p class="text-sm text-foreground">
{{ retentionFormValue.retentionPeriod ?? '-' }}
</p>
</div>
</div>
</ItemContent>
</Item>
</ItemGroup>
</div>
</AppDrawer>
</Drawer>
<AppModal v-model="showPolicyModal" :title="t('Set Policy')" size="xl">
<AppCard padded class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Policy') }}</Label>
<AppSelect
v-model="policyFormValue.policy"
:options="policyOptions"
:placeholder="t('Please select policy')"
/>
</div>
<div v-if="policyFormValue.policy === 'custom'" class="space-y-2">
<Label>{{ t('Policy Content') }}</Label>
<div class="max-h-[60vh] overflow-y-auto rounded-md border p-2">
<json-editor v-model="policyFormValue.content" />
</div>
</div>
</AppCard>
<Modal v-model="showPolicyModal" :title="t('Set Policy')" size="xl">
<div class="space-y-4">
<Field>
<FieldLabel>{{ t('Policy') }}</FieldLabel>
<FieldContent>
<Selector v-model="policyFormValue.policy" :options="policyOptions" :placeholder="t('Please select policy')" />
</FieldContent>
</Field>
<Field v-if="policyFormValue.policy === 'custom'">
<FieldLabel>{{ t('Policy Content') }}</FieldLabel>
<FieldContent>
<div class="max-h-[60vh] overflow-y-auto rounded-md border p-2">
<json-editor v-model="policyFormValue.content" />
</div>
</FieldContent>
</Field>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="showPolicyModal = false">
@@ -143,19 +151,23 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<AppModal v-model="showTagModal" :title="t('Set Tag')" size="md">
<AppCard padded class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Tag Key') }}</Label>
<Input v-model="tagFormValue.name" :placeholder="t('Tag Key Placeholder')" />
</div>
<div class="space-y-2">
<Label>{{ t('Tag Value') }}</Label>
<Input v-model="tagFormValue.value" :placeholder="t('Please enter tag value')" />
</div>
</AppCard>
<Modal v-model="showTagModal" :title="t('Set Tag')" size="md">
<div class="space-y-4">
<Field>
<FieldLabel>{{ t('Tag Key') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.name" :placeholder="t('Tag Key Placeholder')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Tag Value') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.value" :placeholder="t('Please enter tag value')" />
</FieldContent>
</Field>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="showTagModal = false">
@@ -166,27 +178,23 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<AppModal v-model="showEncryptModal" :title="t('Enable Storage Encryption')" size="md">
<AppCard padded class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Encryption Type') }}</Label>
<AppSelect
v-model="encryptFormValue.encrypt"
:options="encryptionOptions"
:placeholder="t('Please select encryption type')"
/>
</div>
<div v-if="encryptFormValue.encrypt === 'SSE-KMS'" class="space-y-2">
<Label>KMS Key ID</Label>
<AppSelect
v-model="encryptFormValue.kmsKeyId"
:options="kmsKeyOptions"
:placeholder="t('Please select KMS key')"
/>
</div>
</AppCard>
<Modal v-model="showEncryptModal" :title="t('Enable Storage Encryption')" size="md">
<div class="space-y-4">
<Field>
<FieldLabel>{{ t('Encryption Type') }}</FieldLabel>
<FieldContent>
<Selector v-model="encryptFormValue.encrypt" :options="encryptionOptions" :placeholder="t('Please select encryption type')" />
</FieldContent>
</Field>
<Field v-if="encryptFormValue.encrypt === 'SSE-KMS'">
<FieldLabel>KMS Key ID</FieldLabel>
<FieldContent>
<Selector v-model="encryptFormValue.kmsKeyId" :options="kmsKeyOptions" :placeholder="t('Please select KMS key')" />
</FieldContent>
</Field>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="showEncryptModal = false">
@@ -197,31 +205,39 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<AppModal v-model="showRetentionModal" :title="t('Set Retention')" size="md">
<AppCard padded class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Retention Mode') }}</Label>
<AppRadioGroup
v-model="retentionFormValue.retentionMode"
:options="retentionModeOptions"
class="grid gap-2 sm:grid-cols-2"
/>
</div>
<div class="space-y-2">
<Label>{{ t('Retention Unit') }}</Label>
<AppRadioGroup
v-model="retentionFormValue.retentionUnit"
:options="retentionUnitOptions"
class="grid gap-2 sm:grid-cols-2"
/>
</div>
<div class="space-y-2">
<Label>{{ t('Retention Period') }}</Label>
<Input v-model="retentionPeriodInput" type="number" />
</div>
</AppCard>
<Modal v-model="showRetentionModal" :title="t('Set Retention')" size="md">
<div class="space-y-4">
<Field>
<FieldLabel>{{ t('Retention Mode') }}</FieldLabel>
<FieldContent>
<RadioGroup v-model="retentionFormValue.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">
<RadioGroupItem :value="option.value" class="mt-0.5" />
<span class="text-sm font-medium">{{ option.label }}</span>
</label>
</RadioGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Retention Unit') }}</FieldLabel>
<FieldContent>
<RadioGroup v-model="retentionFormValue.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">
<RadioGroupItem :value="option.value" class="mt-0.5" />
<span class="text-sm font-medium">{{ option.label }}</span>
</label>
</RadioGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Retention Period') }}</FieldLabel>
<FieldContent>
<Input v-model="retentionPeriodInput" type="number" />
</FieldContent>
</Field>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="showRetentionModal = false">
@@ -232,17 +248,22 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AppCard, AppDrawer, AppModal, AppRadioGroup, AppSelect, AppSpinner } from '@/components/app'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
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'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BucketPolicyType } from '~/utils/bucket-policy'
+64 -38
View File
@@ -1,47 +1,73 @@
<template>
<AppModal v-model="modalVisible" :title="t('Create Bucket')" size="lg" :close-on-backdrop="false">
<Modal v-model="modalVisible" :title="t('Create Bucket')" size="lg" :close-on-backdrop="false">
<div class="space-y-6">
<div class="space-y-2">
<Label for="bucket-name">{{ t('Please enter name') }}</Label>
<Input id="bucket-name" v-model="objectKey" autocomplete="off" :class="[
'w-full',
showNameError && 'border-destructive focus-visible:ring-destructive',
]" />
</div>
<Field>
<FieldLabel for="bucket-name">{{ t('Please enter name') }}</FieldLabel>
<FieldContent>
<Input id="bucket-name" v-model="objectKey" autocomplete="off" :class="[
'w-full',
showNameError && 'border-destructive focus-visible:ring-destructive',
]" />
</FieldContent>
</Field>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>{{ t('Version') }}</Label>
<Field orientation="responsive" class="items-center">
<FieldLabel>{{ t('Version') }}</FieldLabel>
<FieldContent class="flex justify-end">
<Switch v-model:checked="version" />
</div>
</div>
</FieldContent>
</Field>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>{{ t('Object Lock') }}</Label>
<Field orientation="responsive" class="items-center">
<FieldLabel>{{ t('Object Lock') }}</FieldLabel>
<FieldContent class="flex justify-end">
<Switch v-model:checked="objectLock" />
</div>
</div>
</FieldContent>
</Field>
<div v-if="objectLock" class="space-y-4 rounded-lg border p-4">
<div class="flex items-center justify-between">
<Label>{{ t('Retention') }}</Label>
<Switch v-model:checked="retentionEnabled" />
</div>
<Field orientation="responsive" class="items-center">
<FieldLabel>{{ t('Retention') }}</FieldLabel>
<FieldContent class="flex justify-end">
<Switch v-model:checked="retentionEnabled" />
</FieldContent>
</Field>
<div v-if="retentionEnabled" class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Retention Mode') }}</Label>
<AppRadioGroup v-model="retentionMode" :options="retentionModeOptions" class="grid gap-2 sm:grid-cols-2" item-class="h-full" />
</div>
<Field>
<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"
>
<RadioGroupItem :value="option.value" class="mt-0.5" />
<span class="text-sm font-medium">{{ option.label }}</span>
</label>
</RadioGroup>
</FieldContent>
</Field>
<div class="space-y-2">
<Label>{{ t('Validity') }}</Label>
<div class="flex flex-col gap-2 sm:flex-row">
<Input v-model="retentionPeriod" type="number" class="sm:w-32" />
<AppSelect v-model="retentionUnit" :options="retentionUnitOptions" class="sm:w-32" />
</div>
</div>
<Field>
<FieldLabel>{{ t('Validity') }}</FieldLabel>
<FieldContent>
<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"
>
<RadioGroupItem :value="option.value" class="mt-0.5" />
<span class="text-sm font-medium">{{ option.label }}</span>
</label>
</RadioGroup>
</div>
</FieldContent>
</Field>
</div>
</div>
</div>
@@ -56,15 +82,15 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AppModal, AppRadioGroup, AppSelect } from '@/components/app'
import { Label } from '@/components/ui/label'
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'
@@ -95,7 +121,7 @@ const retentionPeriod = ref('180')
const retentionUnit = ref<'day' | 'year'>('day')
const creating = ref(false)
watch(objectLock, value => {
watch(objectLock, (value: boolean) => {
if (value) {
version.value = true
} else {
@@ -103,7 +129,7 @@ watch(objectLock, value => {
}
})
watch(version, value => {
watch(version, (value: boolean) => {
if (!value && objectLock.value) {
objectLock.value = false
}
@@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button'
import type { Table } from '@tanstack/vue-table'
import AppSelect from '@/components/app/AppSelect.vue'
import Selector from '@/components/selector.vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
@@ -42,7 +42,7 @@ const handlePageSizeChange = (value: number | string | boolean | null) => {
<span class="text-sm text-muted-foreground">
Rows per page
</span>
<AppSelect
<Selector
:options="pageSizeOptions.map(option => ({ label: String(option), value: option }))"
:model-value="pagination.pageSize"
class="w-24"
+6 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="TData">
import AppEmpty from '@/components/app/AppEmpty.vue'
import EmptyState from '@/components/empty-state.vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import { Spinner } from '@/components/ui/spinner'
import {
TableBody,
TableCell,
@@ -76,9 +76,9 @@ const hasRows = computed(() => props.table.getRowModel().rows.length > 0)
<template v-else>
<TableRow>
<TableCell :colspan="visibleColumnCount" class="h-48">
<AppEmpty :title="emptyTitle" :description="emptyDescription">
<EmptyState :title="emptyTitle" :description="emptyDescription">
<slot name="empty" />
</AppEmpty>
</EmptyState>
</TableCell>
</TableRow>
</template>
@@ -118,9 +118,9 @@ const hasRows = computed(() => props.table.getRowModel().rows.length > 0)
<template v-else>
<TableRow>
<TableCell :colspan="visibleColumnCount" class="h-48">
<AppEmpty :title="emptyTitle" :description="emptyDescription">
<EmptyState :title="emptyTitle" :description="emptyDescription">
<slot name="empty" />
</AppEmpty>
</EmptyState>
</TableCell>
</TableRow>
</template>
-1
View File
@@ -1 +0,0 @@
export { useDataTable } from './useDataTable'
+30 -59
View File
@@ -1,19 +1,9 @@
<template>
<AppModal
v-model="visible"
:title="formData.type ? t('Add {type} Destination', { type: formData.type }) : t('Add Event Destination')"
size="lg"
:close-on-backdrop="false"
>
<Modal v-model="visible" :title="formData.type ? t('Add {type} Destination', { type: formData.type }) : t('Add Event Destination')" size="lg" :close-on-backdrop="false">
<div class="space-y-6">
<div v-if="!formData.type" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="option in typeOptions"
:key="option.value"
class="cursor-pointer border border-border/70 transition hover:border-primary"
@click="chooseType(option.value)"
>
<div class="flex items-center gap-3">
<div v-for="option in typeOptions" :key="option.value" class="cursor-pointer border border-border/70 transition hover:border-primary" @click="chooseType(option.value)">
<div class="flex items-center gap-3 p-4">
<img :src="option.iconUrl" class="h-10 w-10" alt="" />
<div>
<p class="text-base font-semibold">{{ option.label }}</p>
@@ -24,7 +14,7 @@
</div>
<div v-else class="space-y-4">
<div class="flex cursor-pointer items-center gap-3 border transition hover:border-primary" @click="resetType">
<div class="flex cursor-pointer items-center gap-3 p-4 border transition hover:border-primary" @click="resetType">
<img :src="iconUrl" class="h-10 w-10" alt="" />
<div class="flex flex-col">
<span class="text-sm text-muted-foreground">{{ t('Selected Type') }}</span>
@@ -33,46 +23,27 @@
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="target-name">{{ t('Name') }} (A-Z,0-9,_)</Label>
<Input
id="target-name"
v-model="formData.name"
:placeholder="t('Please enter name')"
autocomplete="off"
@input="validateNameFormat"
/>
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<Field>
<FieldLabel for="target-name">{{ t('Name') }} (A-Z,0-9,_)</FieldLabel>
<FieldContent>
<Input id="target-name" v-model="formData.name" :placeholder="t('Please enter name')" autocomplete="off" @input="validateNameFormat" />
</FieldContent>
<FieldDescription v-if="errors.name" class="text-destructive">
{{ errors.name }}
</FieldDescription>
</Field>
<div
v-for="config in currentConfigOptions"
:key="config.name"
class="grid gap-2"
>
<Label :for="`config-${config.name}`">{{ config.label }}</Label>
<Input
v-if="config.type === 'text'"
:id="`config-${config.name}`"
v-model="formData.config[config.name]"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`"
/>
<Input
v-else-if="config.type === 'password'"
:id="`config-${config.name}`"
v-model="formData.config[config.name]"
type="password"
autocomplete="off"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`"
/>
<Input
v-else-if="config.type === 'number'"
:id="`config-${config.name}`"
v-model="formData.config[config.name]"
type="number"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`"
/>
</div>
<Field v-for="config in currentConfigOptions" :key="config.name">
<FieldLabel :for="`config-${config.name}`">{{ config.label }}</FieldLabel>
<FieldContent>
<Input v-if="config.type === 'text'" :id="`config-${config.name}`" v-model="formData.config[config.name]"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`" />
<Input v-else-if="config.type === 'password'" :id="`config-${config.name}`" v-model="formData.config[config.name]" type="password" autocomplete="off"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`" />
<Input v-else-if="config.type === 'number'" :id="`config-${config.name}`" v-model="formData.config[config.name]" type="number"
:placeholder="`${t('Please enter')} ${config.label.toLowerCase()}`" />
</FieldContent>
</Field>
</div>
</div>
</div>
@@ -85,20 +56,20 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
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 { AppModal } from '@/components/app'
import { Label } from '@/components/ui/label'
import { computed, reactive, ref, watch } from 'vue'
import { useEventTarget } from '#imports'
const { t } = useI18n()
const { updateEventTarget } = useEventTarget()
+54 -48
View File
@@ -16,52 +16,58 @@
</DialogHeader>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-resource-name">{{ t('Amazon Resource Name') }}</Label>
<Select
id="event-resource-name"
v-model="formData.resourceName"
:disabled="!arnList.length"
>
<SelectTrigger>
<SelectValue :placeholder="t('Please select resource name')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in arnList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="errors.resourceName" class="sm:col-start-2 text-sm text-destructive">
<Field orientation="responsive" class="sm:items-center">
<FieldLabel for="event-resource-name">{{ t('Amazon Resource Name') }}</FieldLabel>
<FieldContent>
<Select
id="event-resource-name"
v-model="formData.resourceName"
:disabled="!arnList.length"
>
<SelectTrigger>
<SelectValue :placeholder="t('Please select resource name')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in arnList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
</FieldContent>
<FieldDescription v-if="errors.resourceName" class="text-destructive">
{{ errors.resourceName }}
</p>
</div>
</FieldDescription>
</Field>
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-prefix">{{ t('Prefix') }}</Label>
<Input
id="event-prefix"
v-model="formData.prefix"
:placeholder="t('Please enter prefix')"
/>
</div>
<Field orientation="responsive" class="sm:items-center">
<FieldLabel for="event-prefix">{{ t('Prefix') }}</FieldLabel>
<FieldContent>
<Input
id="event-prefix"
v-model="formData.prefix"
:placeholder="t('Please enter prefix')"
/>
</FieldContent>
</Field>
<div class="grid gap-4 sm:grid-cols-[160px_1fr] sm:items-center">
<Label for="event-suffix">{{ t('Suffix') }}</Label>
<Input
id="event-suffix"
v-model="formData.suffix"
:placeholder="t('Please enter suffix')"
/>
</div>
<Field orientation="responsive" class="sm:items-center">
<FieldLabel for="event-suffix">{{ t('Suffix') }}</FieldLabel>
<FieldContent>
<Input
id="event-suffix"
v-model="formData.suffix"
:placeholder="t('Please enter suffix')"
/>
</FieldContent>
</Field>
<div class="grid gap-4 sm:grid-cols-[160px_1fr]">
<Label>{{ t('Select events') }}</Label>
<div class="space-y-2">
<Field orientation="responsive">
<FieldLabel>{{ t('Select events') }}</FieldLabel>
<FieldContent>
<ScrollArea class="max-h-64 rounded-md border">
<div class="flex flex-col gap-2 p-4">
<label
@@ -78,11 +84,11 @@
</label>
</div>
</ScrollArea>
<p v-if="errors.events" class="text-sm text-destructive">
{{ errors.events }}
</p>
</div>
</div>
</FieldContent>
<FieldDescription v-if="errors.events" class="text-destructive">
{{ errors.events }}
</FieldDescription>
</Field>
</div>
<div class="flex flex-col gap-2 pt-6 sm:flex-row sm:justify-center">
@@ -107,7 +113,7 @@ import {
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
import {
Select,
SelectContent,
-11
View File
@@ -1,11 +0,0 @@
<template>
<div>
{{ t('Footer') }}
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
-11
View File
@@ -1,11 +0,0 @@
<template>
<div>
{{ t('Header') }}
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
+13 -17
View File
@@ -1,12 +1,8 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="w-full justify-start gap-2 px-2 transition-[padding] duration-200 group-data-[collapsible=icon]:h-9 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"
>
<Button variant="ghost">
<Icon :name="currentLanguage.icon" class="h-4 w-4 shrink-0" />
<span class="truncate group-data-[collapsible=icon]:hidden">{{ currentLanguage.text }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40" align="start">
@@ -18,36 +14,36 @@
</template>
<script setup lang="ts">
import { Icon } from '#components';
import { Button } from '@/components/ui/button';
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
} from '@/components/ui/dropdown-menu'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { locale, setLocale } = useI18n();
const { locale, setLocale } = useI18n()
const languageConfig = {
en: { text: 'English', icon: 'ri:translate' },
zh: { text: '中文', icon: 'ri:translate-2' },
tr: { text: 'Türkçe', icon: 'ri:translate' },
} as const;
} as const
const options = [
{ label: 'English', key: 'en' },
{ label: '中文', key: 'zh' },
{ label: 'Türkçe', key: 'tr' },
];
]
const currentLanguage = computed(() => {
return languageConfig[locale.value as keyof typeof languageConfig] || languageConfig.en;
});
return languageConfig[locale.value as keyof typeof languageConfig] || languageConfig.en
})
const handleSelect = async (key: string) => {
await setLocale(key as 'en' | 'zh' | 'tr');
};
await setLocale(key as 'en' | 'zh' | 'tr')
}
</script>
+75 -95
View File
@@ -1,10 +1,5 @@
<template>
<AppModal
v-model="visible"
:title="`${t('Add Lifecycle Rule')} (${t('Bucket')}: ${bucketName})`"
size="lg"
:close-on-backdrop="false"
>
<Modal v-model="visible" :title="`${t('Add Lifecycle Rule')} (${t('Bucket')}: ${bucketName})`" size="lg" :close-on-backdrop="false">
<div class="space-y-6">
<Tabs v-model="activeTab" class="flex flex-col gap-4">
<TabsList class="w-full justify-start overflow-x-auto">
@@ -14,59 +9,55 @@
<TabsContent value="expire" class="mt-0 space-y-6">
<div class="space-y-4">
<div v-if="versioningStatus" class="grid gap-2">
<Label>{{ t('Object Version') }}</Label>
<AppSelect v-model="formData.versionType" :options="versionOptions" />
</div>
<Field v-if="versioningStatus">
<FieldLabel>{{ t('Object Version') }}</FieldLabel>
<FieldContent>
<Selector v-model="formData.versionType" :options="versionOptions" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Time Cycle') }}</Label>
<div class="flex items-center gap-3">
<Input
v-model="formData.days"
type="number"
min="1"
class="w-32"
:placeholder="t('Days')"
/>
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</div>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel>{{ t('Time Cycle') }}</FieldLabel>
<FieldDescription>{{ t('Set the time cycle for the rule') }}</FieldDescription>
</FieldContent>
<FieldContent>
<div class="flex items-center justify-end gap-3">
<Input v-model="formData.days" type="number" min="1" class="w-32" :placeholder="t('Days')" />
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</FieldContent>
</Field>
</div>
<div class="space-y-4">
<details>
<summary class="cursor-pointer text-sm font-medium text-primary">{{ t('More Configurations') }}</summary>
<div class="mt-4 space-y-4">
<div class="grid gap-2">
<Label>{{ t('Prefix') }}</Label>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel>{{ t('Prefix') }}</FieldLabel>
<FieldDescription>{{ t('Set the prefix for the rule') }}</FieldDescription>
</FieldContent>
<FieldContent>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</FieldContent>
</Field>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
<FieldLabel class="text-sm font-medium">{{ t('Tags') }}</FieldLabel>
<Button variant="outline" size="sm" @click="addTag">
<Icon name="ri:add-line" class="size-4" />
{{ t('Add Tag') }}
</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 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>
@@ -80,83 +71,71 @@
<div v-if="formData.versionType === 'current'">
<details>
<summary class="cursor-pointer text-sm font-medium text-primary">{{ t('Advanced Settings') }}</summary>
<div class="mt-4 flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('Delete Marker Handling') }}</p>
<p class="text-xs text-muted-foreground">
{{ t('If no versions remain, delete references to this object') }}
</p>
</div>
<Switch v-model:checked="formData.expiredDeleteMark" />
</div>
<Field orientation="horizontal" class="mt-4">
<FieldContent>
<FieldLabel class="text-sm font-medium">{{ t('Delete Marker Handling') }}</FieldLabel>
<FieldDescription>{{ t('If no versions remain, delete references to this object') }}</FieldDescription>
</FieldContent>
<FieldContent>
<Switch v-model="formData.expiredDeleteMark" class="ml-auto" />
</FieldContent>
</Field>
</details>
</div>
</TabsContent>
<TabsContent value="transition" class="mt-0 space-y-6">
<div class="space-y-4">
<div v-if="versioningStatus" class="grid gap-2">
<Label>{{ t('Object Version') }}</Label>
<AppSelect v-model="formData.versionType" :options="versionOptions" />
</div>
<Field v-if="versioningStatus">
<FieldLabel>{{ t('Object Version') }}</FieldLabel>
<FieldContent>
<Selector v-model="formData.versionType" :options="versionOptions" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Time Cycle') }}</Label>
<div class="flex items-center gap-3">
<Input
v-model="formData.days"
type="number"
min="1"
class="w-32"
:placeholder="t('Days')"
/>
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</div>
<Field>
<FieldLabel>{{ t('Time Cycle') }}</FieldLabel>
<FieldContent>
<div class="flex items-center gap-3">
<Input v-model="formData.days" type="number" min="1" class="w-32" :placeholder="t('Days')" />
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Storage Type') }}</Label>
<AppSelect
v-model="formData.storageType"
:options="tiers"
:placeholder="t('Please select storage type')"
/>
</div>
<Field>
<FieldLabel>{{ t('Storage Type') }}</FieldLabel>
<FieldContent>
<Selector v-model="formData.storageType" :options="tiers" :placeholder="t('Please select storage type')" />
</FieldContent>
</Field>
</div>
<div class="space-y-4">
<details>
<summary class="cursor-pointer text-sm font-medium text-primary">{{ t('More Configurations') }}</summary>
<div class="mt-4 space-y-4">
<div class="grid gap-2">
<Label>{{ t('Prefix') }}</Label>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<Field>
<FieldLabel>{{ t('Prefix') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</FieldContent>
</Field>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
<FieldLabel class="text-sm font-medium">{{ t('Tags') }}</FieldLabel>
<Button variant="outline" size="sm" @click="addTag">
<Icon name="ri:add-line" class="size-4" />
{{ t('Add Tag') }}
</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>
@@ -176,18 +155,19 @@
<Button variant="default" :loading="submitting" @click="handleSave">{{ t('Save') }}</Button>
</div>
</template>
</AppModal>
</Modal>
</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 { AppModal, AppSelect } from '@/components/app'
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 { Label } from '@/components/ui/label'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -61,12 +61,8 @@ const handleEscape = (event: Event) => {
<template>
<Dialog :open="modelValue" @update:open="handleUpdateOpen">
<DialogContent
:class="cn(sizeClassMap[size], props.class, contentClass)"
@pointerDownOutside="handlePointerOutside"
@interactOutside="handlePointerOutside"
@escapeKeyDown="handleEscape"
>
<DialogContent :class="cn(sizeClassMap[size], props.class, contentClass)" @pointerDownOutside="handlePointerOutside" @interactOutside="handlePointerOutside"
@escapeKeyDown="handleEscape">
<DialogHeader v-if="title || description || $slots.header" class="text-left">
<slot name="header">
<DialogTitle v-if="title">{{ title }}</DialogTitle>
@@ -74,7 +70,7 @@ const handleEscape = (event: Event) => {
</slot>
</DialogHeader>
<div>
<div class="max-h-[75vh]">
<slot />
</div>
+7 -4
View File
@@ -1,12 +1,15 @@
<template>
<div class="flex flex-col gap-3">
<div v-if="tasks.length <= 0" class="mx-auto text-muted-foreground text-center">{{ t('No Tasks') }}</div>
<EmptyState v-if="tasks.length <= 0" :title="t('No Tasks')" class="py-6" />
<object-delete-task-item v-for="task in tasks" :key="task.id" :task="task" />
</div>
</template>
<script setup lang="ts">
import type { DeleteTask } from '~/lib/delete-task-manager';
const { t } = useI18n();
const props = defineProps<{ tasks: DeleteTask[] }>();
import EmptyState from '@/components/empty-state.vue'
import type { DeleteTask } from '~/lib/delete-task-manager'
const { t } = useI18n()
defineProps<{ tasks: DeleteTask[] }>()
</script>
+52 -31
View File
@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="visible" :title="t('Object Details')" size="lg">
<Drawer v-model="visible" :title="t('Object Details')" size="lg">
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="download">
@@ -24,8 +24,11 @@
</Button>
</div>
<AppCard :title="t('Info')" padded>
<div class="space-y-3 text-sm">
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemHeader class="items-center">
<ItemTitle>{{ t('Info') }}</ItemTitle>
</ItemHeader>
<ItemContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Name') }}</span>
<span>{{ object?.Key }}</span>
@@ -48,7 +51,7 @@
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Legal Hold') }}</span>
<Switch :checked="lockStatus" @update:checked="toggleLegalHold" />
<Switch :checked="lockStatus" @update:checked="toggleLegalHold" />
</div>
<div class="flex flex-col gap-2">
<span class="font-medium text-muted-foreground">{{ t('Retention') + t('Policy') }}</span>
@@ -63,42 +66,57 @@
{{ t('Copy') }}
</Button>
</div>
</div>
</AppCard>
</ItemContent>
</Item>
</div>
<AppModal v-model="showTagView" :title="t('Set Tags')" size="lg">
<Modal v-model="showTagView" :title="t('Set Tags')" size="lg">
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<AppTag v-for="tag in tags" :key="tag.Key" tone="info">
<Badge v-for="tag in tags" :key="tag.Key" variant="secondary">
{{ tag.Key }}: {{ tag.Value }}
</AppTag>
</Badge>
</div>
<form class="space-y-4" @submit.prevent="submitTagForm">
<div class="flex items-center gap-2">
<Input v-model="tagFormValue.Key" :placeholder="t('Tag Key Placeholder')" />
<span>=</span>
<Input v-model="tagFormValue.Value" :placeholder="t('Tag Value Placeholder')" />
<div class="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>{{ t('Tag Key') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.Key" :placeholder="t('Tag Key Placeholder')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Tag Value') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.Value" :placeholder="t('Tag Value Placeholder')" />
</FieldContent>
</Field>
</div>
<div class="flex justify-end">
<Button type="submit" variant="default">{{ t('Add') }}</Button>
</div>
<Button type="submit" variant="default">{{ t('Add') }}</Button>
</form>
</div>
</AppModal>
</Modal>
<AppModal v-model="showRetentionView" :title="t('Retention')" size="lg">
<Modal v-model="showRetentionView" :title="t('Retention')" size="lg">
<div class="space-y-4">
<form class="flex flex-col gap-3" @submit.prevent="submitRetention">
<div class="flex flex-col gap-2">
<Label>{{ t('Retention Mode') }}</Label>
<AppRadioGroup v-model="retentionMode" :options="[
{ label: t('COMPLIANCE'), value: 'COMPLIANCE' },
{ label: t('GOVERNANCE'), value: 'GOVERNANCE' },
]" />
</div>
<div class="flex flex-col gap-2">
<Label>{{ t('Retention RetainUntilDate') }}</Label>
<Input v-model="retainUntilDate" type="datetime-local" />
</div>
<Field>
<FieldLabel>{{ t('Retention Mode') }}</FieldLabel>
<FieldContent>
<v-radio-group v-model="retentionMode" :options="[
{ label: t('COMPLIANCE'), value: 'COMPLIANCE' },
{ label: t('GOVERNANCE'), value: 'GOVERNANCE' },
]" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Retention RetainUntilDate') }}</FieldLabel>
<FieldContent>
<Input v-model="retainUntilDate" type="datetime-local" />
</FieldContent>
</Field>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="resetRetention">{{ t('Reset') }}</Button>
<Button type="submit" variant="default">{{ t('Confirm') }}</Button>
@@ -106,19 +124,22 @@
</div>
</form>
</div>
</AppModal>
</Modal>
<object-preview-modal v-model:show="showPreview" :object="object" />
</AppDrawer>
</Drawer>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AppCard, AppDrawer, AppModal, AppRadioGroup, AppTag } from '@/components/app'
import Drawer from '@/components/drawer.vue'
import Modal from '@/components/modal.vue'
import { Item, ItemContent, ItemHeader, ItemTitle } from '@/components/ui/item'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { joinRelativeURL } from 'ufo'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
+47 -44
View File
@@ -1,34 +1,32 @@
<template>
<div class="space-y-4">
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div class="flex flex-wrap items-center justify-between gap-3">
<Input v-model="searchTerm" :placeholder="t('Search')" class="max-w-md" @input="handleSearch" />
<div class="flex flex-wrap items-center gap-2">
<object-upload-stats />
<object-delete-stats />
<Button variant="secondary" @click="() => handleNewObject(true)">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('New Folder') }}</span>
</Button>
<Button variant="secondary" @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">
<Icon name="ri:delete-bin-5-line" class="size-4" />
<span>{{ t('Delete Selected') }}</span>
</Button>
<Button variant="outline" :disabled="!checkedKeys.length" v-show="checkedKeys.length" @click="downloadMultiple">
<Icon name="ri:download-cloud-2-line" class="size-4" />
<span>{{ t('Download') }}</span>
</Button>
<Button variant="outline" @click="handleRefresh">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
</div>
</div>
<div class="space-y-6">
<page-header>
<SearchInput v-model="searchTerm" :placeholder="t('Search')" clearable />
<template #actions>
<object-upload-stats />
<object-delete-stats />
<Button variant="secondary" @click="() => handleNewObject(true)">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('New Folder') }}</span>
</Button>
<Button variant="secondary" @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">
<Icon name="ri:delete-bin-5-line" class="size-4" />
<span>{{ t('Delete Selected') }}</span>
</Button>
<Button variant="outline" :disabled="!checkedKeys.length" v-show="checkedKeys.length" @click="downloadMultiple">
<Icon name="ri:download-cloud-2-line" class="size-4" />
<span>{{ t('Download') }}</span>
</Button>
<Button variant="outline" @click="handleRefresh">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</template>
</page-header>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Objects')" :empty-description="t('Upload files or create folders to populate this bucket.')" />
@@ -42,24 +40,25 @@
<Icon name="ri:arrow-right-s-line" class="ml-2" />
</Button>
</div>
<object-upload-picker :show="uploadPickerVisible" :bucketName="bucketName" :prefix="prefix" @update:show="val => {
uploadPickerVisible = val
refresh()
}" />
<object-new-form :show="newObjectFormVisible" :bucketName="bucketName" :prefix="prefix" :asPrefix="newObjectAsPrefix" @update:show="val => {
newObjectFormVisible = val
refresh()
}" />
<object-info ref="infoRef" :bucket-name="bucketName" @refresh-parent="handleObjectDeleted" />
</div>
<object-upload-picker :show="uploadPickerVisible" :bucketName="bucketName" :prefix="prefix" @update:show="val => {
uploadPickerVisible = val
refresh()
}" />
<object-new-form :show="newObjectFormVisible" :bucketName="bucketName" :prefix="prefix" :asPrefix="newObjectAsPrefix" @update:show="val => {
newObjectFormVisible = val
refresh()
}" />
<object-info ref="infoRef" :bucket-name="bucketName" @refresh-parent="handleObjectDeleted" />
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Icon, NuxtLink } from '#components'
import DataTable from '@/components/data-table/data-table.vue'
import { useDataTable } from '@/components/data-table/useDataTable'
import { ListObjectsV2Command } from '@aws-sdk/client-s3'
import type { ColumnDef } from '@tanstack/vue-table'
import dayjs from 'dayjs'
@@ -67,8 +66,6 @@ import { saveAs } from 'file-saver'
import JSZip from 'jszip'
import { joinRelativeURL } from 'ufo'
import { computed, h, ref, watch } from 'vue'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
import { useDeleteTaskManagerStore } from '~/store/delete-tasks'
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
import { resolveRouteParam, safeDecodeURIComponent } from '~/utils/functions'
@@ -102,6 +99,10 @@ const handleSearch = debounce(() => {
refresh()
}, 300)
watch(searchTerm, () => {
handleSearch()
})
const uploadTaskStore = useUploadTaskManagerStore()
const deleteTaskStore = useDeleteTaskManagerStore()
@@ -192,6 +193,7 @@ const columns = computed<ColumnDef<ObjectRow, any>[]>(() => {
header: ({ table }: any) =>
h('input', {
type: 'checkbox',
class: 'w-8',
checked: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected(),
onChange: (event: Event) => table.toggleAllPageRowsSelected((event.target as HTMLInputElement).checked),
@@ -199,6 +201,7 @@ const columns = computed<ColumnDef<ObjectRow, any>[]>(() => {
cell: ({ row }: any) =>
h('input', {
type: 'checkbox',
class: 'w-8',
checked: row.getIsSelected(),
onChange: (event: Event) => row.toggleSelected((event.target as HTMLInputElement).checked),
}),
@@ -268,7 +271,7 @@ const columns = computed<ColumnDef<ObjectRow, any>[]>(() => {
header: () => t('Actions'),
enableSorting: false,
cell: ({ row }: any) =>
h('div', { class: 'flex justify-center gap-2' }, [
h('div', { class: 'flex items-center gap-2' }, [
row.original.type === 'object'
? h(
Button,
+4 -4
View File
@@ -1,5 +1,5 @@
<template>
<AppModal v-model="modalVisible" :title="t('New Form', { type: displayType })" size="md" :close-on-backdrop="false">
<Modal v-model="modalVisible" :title="t('New Form', { type: displayType })" size="md" :close-on-backdrop="false">
<div class="space-y-4">
<Alert>
<AlertDescription>{{ t('Overwrite Warning') }}</AlertDescription>
@@ -18,7 +18,7 @@
</Button>
</div>
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
@@ -28,7 +28,7 @@ import { Button } from '@/components/ui/button'
import { joinRelativeURL } from 'ufo'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { AppModal } from '@/components/app'
import Modal from '@/components/modal.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
const { t } = useI18n()
@@ -69,4 +69,4 @@ const handlePutObject = async () => {
$message.error(error?.message || t('Create Failed'))
}
}
</script>
</script>
+5 -5
View File
@@ -1,8 +1,8 @@
<template>
<AppModal 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">
<div class="flex flex-col gap-4">
<div class="min-h-[300px] rounded-md border p-4 flex flex-col">
<AppSpinner v-if="loading" class="mx-auto size-8" />
<Spinner v-if="loading" class="mx-auto size-8 text-muted-foreground" />
<template v-else>
<div v-if="isImage" class="flex justify-center">
<img :src="previewUrl" alt="preview" class="max-h-[60vh]" />
@@ -23,12 +23,12 @@
</template>
</div>
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { AppModal, AppSpinner } from '@/components/app'
import Modal from '@/components/modal.vue'
import { Spinner } from '@/components/ui/spinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
+8 -15
View File
@@ -1,13 +1,7 @@
<template>
<Button
v-if="total > 0"
variant="ghost"
size="sm"
class="h-auto px-0 text-sm font-medium text-primary hover:text-primary"
@click="toggleDrawer"
>
<Button v-if="total > 0" variant="outline" @click="toggleDrawer">
<div v-if="pending.length" class="flex items-center gap-2">
<AppSpinner size="sm" />
<Spinner class="size-3 text-muted-foreground" />
<span>
{{
t('In Progress', {
@@ -23,11 +17,9 @@
</div>
</Button>
<AppDrawer v-model="showDrawer" :title="t('Task Management')" size="lg">
<Drawer v-model="showDrawer" :title="t('Task Management')" size="lg">
<div class="flex flex-col gap-4">
<Alert
class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<Alert class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<AlertDescription class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-amber-600 dark:text-amber-300">{{ t('Browser Warning') }}</span>
@@ -72,15 +64,16 @@
</TabsContent>
</Tabs>
</div>
</AppDrawer>
</Drawer>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { AppDrawer, AppSpinner } from '@/components/app'
import Progress from '@/components/ui/progress/Progress.vue'
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'
+23 -56
View File
@@ -1,15 +1,8 @@
<template>
<AppModal v-model="visible" :title="t('Upload File')" size="xl" :close-on-backdrop="false">
<Modal v-model="visible" :title="t('Upload File')" size="xl" :close-on-backdrop="false">
<div class="space-y-5">
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelect" />
<input
ref="folderInput"
type="file"
webkitdirectory
directory
class="hidden"
@change="handleFolderSelect"
/>
<input ref="folderInput" type="file" webkitdirectory directory class="hidden" @change="handleFolderSelect" />
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
@@ -20,7 +13,8 @@
{{ t('Current Prefix') }}: {{ prefix || '/' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<ActionBar>
<Button variant="outline" size="sm" :disabled="isFolderLoading" @click="selectFile">
<Icon name="ri:file-add-line" class="size-4" />
{{ t('Select File') }}
@@ -29,11 +23,7 @@
<Icon name="ri:folder-add-line" class="size-4" />
{{ t('Select Folder') }}
</Button>
<Button variant="secondary" size="sm" @click="closeModal">
<Icon name="ri:close-line" class="size-4" />
{{ t('Close') }}
</Button>
</div>
</ActionBar>
</div>
<Alert class="border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100">
@@ -42,18 +32,16 @@
</AlertDescription>
</Alert>
<Alert
v-if="isMemoryWarning"
class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<Alert v-if="isMemoryWarning" class="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<AlertDescription>
{{ t('Large File Count Warning', { count: totalFileCount.toLocaleString(), max: MAX_FILES_LIMIT }) }}
</AlertDescription>
</Alert>
<div class="space-y-4 border">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1 text-sm text-muted-foreground">
<div class="border">
<!-- objects info -->
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-2">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<p v-if="totalFileCount > 0">
{{ t('Total Files') }}: {{ totalFileCount.toLocaleString() }}
</p>
@@ -61,29 +49,16 @@
{{ t('Memory Usage') }}: {{ getMemoryUsageLevel() }}
</p>
</div>
<Button
variant="outline"
size="sm"
:disabled="!hasFiles || isFolderLoading || isAdding"
@click="clearAllFiles"
>
<Button variant="outline" size="sm" :disabled="!hasFiles || isFolderLoading || isAdding" @click="clearAllFiles">
<Icon name="ri:delete-bin-line" class="size-4" />
{{ t('Clear All') }}
</Button>
</div>
<div
class="rounded-md border border-dashed transition"
:class="isDragOver ? 'border-primary bg-primary/5' : ''"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
v-if="!selectedItems.length"
class="flex h-[42vh] flex-col items-center justify-center gap-4 p-6 text-center"
>
<!-- objects list / drag area -->
<div class="rounded-md border-t transition" :class="isDragOver ? 'border-primary bg-primary/5' : ''" @dragenter.prevent="handleDragEnter" @dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave" @drop.prevent="handleDrop">
<div v-if="!selectedItems.length" class="flex h-[42vh] flex-col items-center justify-center gap-4 p-6 text-center">
<Icon name="ri:cloud-upload-line" class="size-10 text-muted-foreground" />
<p class="text-base font-medium text-muted-foreground">{{ t('No Selection') }}</p>
<p class="max-w-[320px] text-sm text-muted-foreground">
@@ -93,7 +68,7 @@
</div>
<div v-else class="max-h-[42vh] overflow-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground">
<thead class="sticky top-0 bg-muted text-xs uppercase text-muted-foreground">
<tr>
<th class="px-3 py-2 text-left font-medium">
{{ t('Name') }}
@@ -110,12 +85,9 @@
<tr v-for="(item, index) in selectedItems" :key="item.uid" class="border-b last:border-b-0">
<td class="px-3 py-2">
<div class="flex items-start gap-2">
<Icon
:name="item.type === 'folder' ? 'ri:folder-3-line' : 'ri:file-line'"
class="mt-0.5 size-4 text-muted-foreground"
/>
<Icon :name="item.type === 'folder' ? 'ri:folder-3-line' : 'ri:file-line'" class="mt-0.5 size-4 text-muted-foreground" />
<div>
<div class="font-medium text-foreground">
<div class="font-medium text-foreground truncate max-w-md">
{{ item.name }}<span v-if="item.type === 'folder'">/</span>
</div>
<div v-if="item.type === 'folder' && item.fileCount" class="text-xs text-muted-foreground">
@@ -160,26 +132,21 @@
<Button variant="outline" :disabled="!hasFiles || isAdding || isFolderLoading">
{{ t('Configure') }}
</Button>
<Button
variant="default"
:disabled="!hasFiles || isAdding || isFolderLoading"
:loading="isAdding"
@click="handleUpload"
>
<Button variant="default" :disabled="!hasFiles || isAdding || isFolderLoading" :loading="isAdding" @click="handleUpload">
{{ t('Start Upload') }}
</Button>
</div>
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppModal } from '@/components/app'
import Progress from '@/components/ui/progress/Progress.vue'
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 { useUploadTaskManagerStore } from '~/store/upload-tasks'
@@ -254,7 +221,7 @@ const clearAllFiles = () => {
if (typeof window !== 'undefined' && 'gc' in window && typeof (window as any).gc === 'function') {
try {
;(window as any).gc()
; (window as any).gc()
} catch {
// ignore
}
+8 -19
View File
@@ -1,35 +1,23 @@
<template>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center justify-between gap-3 group">
<div class="truncate text-sm font-medium text-foreground">{{ task.file.name }}</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
v-if="task.status === 'uploading'"
variant="ghost"
size="sm"
class="h-auto px-2 text-xs"
@click="handlePauseTask"
>
<Button v-if="task.status === 'uploading'" variant="ghost" size="sm" class="text-xs" @click="handlePauseTask">
{{ t('Pause') }}
</Button>
<Button
v-if="task.status === 'paused'"
variant="ghost"
size="sm"
class="h-auto px-2 text-xs"
@click="handleResumeTask"
>
<Button v-if="task.status === 'paused'" variant="ghost" size="sm" class="text-xs" @click="handleResumeTask">
{{ t('Resume') }}
</Button>
<Button variant="ghost" size="sm" class="h-auto px-2 text-xs" @click="handleDeleteTask">
{{ t('Delete Record') }}
<Button variant="ghost" size="sm" class="opacity-0 group-hover:opacity-100 text-xs" @click="handleDeleteTask">
<Trash2Icon class="size-4 text-red-500" />
</Button>
</div>
</div>
<Progress :model-value="task.progress" class="h-[2px]" />
<Progress v-model="task.progress" class="h-0.5" />
<div class="flex items-center justify-between text-muted-foreground">
<div>{{ formatBytes(task.file.size) }}</div>
<div class="text-muted-foreground">
<div class="text-muted-foreground text-xs">
<span v-if="task.status === 'pending'">{{ t('Waiting') }}</span>
<span v-else-if="task.status === 'uploading'">{{ t('Uploading Status') }}</span>
<span v-else-if="task.status === 'completed'">{{ t('Success Status') }}</span>
@@ -44,6 +32,7 @@
import { Button } from '@/components/ui/button'
import Progress from '@/components/ui/progress/Progress.vue'
import { Trash2Icon } from 'lucide-vue-next'
import type { UploadTask } from '~/lib/upload-task-manager'
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
import { formatBytes } from '~/utils/functions'
+7 -6
View File
@@ -1,14 +1,15 @@
<template>
<div class="flex flex-col gap-3">
<div v-if="tasks.length <= 0" class="mx-auto text-muted-foreground text-center">
{{ t('No Tasks') }}
</div>
<EmptyState v-if="tasks.length <= 0" :title="t('No Tasks')" class="py-6" />
<object-upload-task-item v-for="task in tasks" :key="task.id" :task="task" />
</div>
</template>
<script setup lang="ts">
import type { UploadTask } from '~/lib/upload-task-manager';
const { t } = useI18n();
defineProps<{ tasks: UploadTask[] }>();
import EmptyState from '@/components/empty-state.vue'
import type { UploadTask } from '~/lib/upload-task-manager'
const { t } = useI18n()
defineProps<{ tasks: UploadTask[] }>()
</script>
+14 -11
View File
@@ -1,26 +1,29 @@
<template>
<AppModal v-model="visibleProxy" :title="t('Object Versions')" size="lg" :close-on-backdrop="false">
<AppCard padded>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Versions')" />
<div class="mt-4 flex justify-end">
<Button variant="outline" @click="closeModal">{{ t('Close') }}</Button>
</div>
</AppCard>
</AppModal>
<Modal v-model="visibleProxy" :title="t('Object Versions')" size="lg" :close-on-backdrop="false">
<Card class="shadow-none">
<CardContent class="space-y-4 p-6">
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Versions')" />
<div class="flex justify-end">
<Button variant="outline" @click="closeModal">{{ t('Close') }}</Button>
</div>
</CardContent>
</Card>
</Modal>
</template>
<script setup lang="tsx">
import { Button } from '@/components/ui/button'
import { AppCard, AppModal } from '@/components/app'
import DataTable from '@/components/data-table/data-table.vue'
import { useDataTable } from '@/components/data-table/useDataTable'
import Modal from '@/components/modal.vue'
import { Card, CardContent } from '@/components/ui/card'
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, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const props = defineProps<{
bucketName: string
+85 -68
View File
@@ -1,83 +1,97 @@
<template>
<div class="space-y-4">
<AppCard padded class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" @click="router.back">
<Icon name="ri:arrow-left-line" class="size-4" />
</Button>
<h2 class="text-lg font-semibold">{{ object?.Key }}</h2>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemContent class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" @click="router.back">
<Icon name="ri:arrow-left-line" class="size-4" />
</Button>
<h2 class="text-lg font-semibold">{{ object?.Key }}</h2>
</div>
<p class="text-sm text-muted-foreground">{{ t('Object Detail Description', { bucket: bucketName }) }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="download">
<Icon name="ri:download-line" class="size-4" />
{{ t('Download') }}
</Button>
<Button variant="outline" size="sm" @click="copySignedUrl">
<Icon name="ri:file-copy-line" class="size-4" />
{{ t('Copy Temporary URL') }}
</Button>
<Button variant="outline" size="sm" @click="() => (showPreview = true)">
<Icon name="ri:eye-line" class="size-4" />
{{ t('Preview') }}
</Button>
<Button variant="outline" size="sm" @click="() => (showTagView = true)">
<Icon name="ri:price-tag-3-line" class="size-4" />
{{ t('Set Tags') }}
</Button>
<Button variant="destructive" size="sm" @click="confirmDelete">
<Icon name="ri:delete-bin-5-line" class="size-4" />
{{ t('Delete') }}
</Button>
<Button variant="outline" size="sm" @click="refresh">
<Icon name="ri:refresh-line" class="size-4" />
{{ t('Refresh') }}
</Button>
</div>
<p class="text-sm text-muted-foreground">{{ t('Object Detail Description', { bucket: bucketName }) }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="download">
<Icon name="ri:download-line" class="size-4" />
{{ t('Download') }}
</Button>
<Button variant="outline" size="sm" @click="copySignedUrl">
<Icon name="ri:file-copy-line" class="size-4" />
{{ t('Copy Temporary URL') }}
</Button>
<Button variant="outline" size="sm" @click="() => (showPreview = true)">
<Icon name="ri:eye-line" class="size-4" />
{{ t('Preview') }}
</Button>
<Button variant="outline" size="sm" @click="() => (showTagView = true)">
<Icon name="ri:price-tag-3-line" class="size-4" />
{{ t('Set Tags') }}
</Button>
<Button variant="destructive" size="sm" @click="confirmDelete">
<Icon name="ri:delete-bin-5-line" class="size-4" />
{{ t('Delete') }}
</Button>
<Button variant="outline" size="sm" @click="refresh">
<Icon name="ri:refresh-line" class="size-4" />
{{ t('Refresh') }}
</Button>
</ItemContent>
</Item>
<Item variant="outline" class="flex-col items-stretch gap-4">
<ItemContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Name') }}</span>
<span>{{ object?.Key }}</span>
</div>
</div>
</AppCard>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Size') }}</span>
<span>{{ object?.ContentLength }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Type') }}</span>
<span>{{ object?.ContentType }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">ETag</span>
<span>{{ object?.ETag }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Last Modified Time') }}</span>
<span>{{ object?.LastModified }}</span>
</div>
</ItemContent>
</Item>
<AppCard padded class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Name') }}</span>
<span>{{ object?.Key }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Size') }}</span>
<span>{{ object?.ContentLength }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Object Type') }}</span>
<span>{{ object?.ContentType }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">ETag</span>
<span>{{ object?.ETag }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-medium text-muted-foreground">{{ t('Last Modified Time') }}</span>
<span>{{ object?.LastModified }}</span>
</div>
</AppCard>
<AppModal v-model="showTagView" :title="t('Set Tags')" size="lg">
<Modal v-model="showTagView" :title="t('Set Tags')" size="lg">
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<AppTag v-for="tag in tags" :key="tag.Key" tone="info">
<Badge v-for="tag in tags" :key="tag.Key" variant="secondary">
{{ tag.Key }}: {{ tag.Value }}
</AppTag>
</Badge>
</div>
<form class="flex flex-wrap gap-3" @submit.prevent="submitTagForm">
<Input v-model="tagFormValue.Key" :placeholder="t('Tag Key Placeholder')" />
<Input v-model="tagFormValue.Value" :placeholder="t('Tag Value Placeholder')" />
<Button type="submit" variant="default">{{ t('Add') }}</Button>
<Button variant="outline" @click="showTagView = false">{{ t('Cancel') }}</Button>
<Field class="min-w-[200px] flex-1">
<FieldLabel>{{ t('Tag Key') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.Key" :placeholder="t('Tag Key Placeholder')" />
</FieldContent>
</Field>
<Field class="min-w-[200px] flex-1">
<FieldLabel>{{ t('Tag Value') }}</FieldLabel>
<FieldContent>
<Input v-model="tagFormValue.Value" :placeholder="t('Tag Value Placeholder')" />
</FieldContent>
</Field>
<Button type="submit" variant="default" class="self-end">{{ t('Add') }}</Button>
<Button type="button" variant="outline" class="self-end" @click="showTagView = false">{{ t('Cancel') }}</Button>
</form>
</div>
</AppModal>
</Modal>
<object-preview-modal :show="showPreview" :object="object" @update:show="showPreview = $event" />
</div>
@@ -88,7 +102,10 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppCard, AppModal, AppTag } from '@/components/app'
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 { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
+7 -4
View File
@@ -1,8 +1,11 @@
<template>
<div class="sticky top-0 z-10 flex flex-col md:flex-row justify-between gap-2">
<slot name="title"></slot>
<div class="flex-1 flex flex-wrap items-center justify-end gap-2">
<slot name="actions"></slot>
<div class="sticky top-0 z-10 flex flex-col justify-between gap-2 md:flex-row">
<div class="space-y-2">
<slot />
<slot name="description"></slot>
</div>
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
<slot name="actions" />
</div>
</div>
</template>
+24 -16
View File
@@ -2,8 +2,8 @@
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { AppModal } from '@/components/app'
import { Label } from '@/components/ui/label'
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'
@@ -114,26 +114,34 @@ const submitForm = async () => {
</script>
<template>
<AppModal
<Modal
v-model="modalVisible"
:title="t('Policy Original')"
size="lg"
:close-on-backdrop="false"
>
<div class="space-y-4">
<div class="grid gap-2">
<Label for="policy-name">{{ t('Policy Name') }}</Label>
<Input id="policy-name" v-model="form.name" autocomplete="off" />
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<Field>
<FieldLabel for="policy-name">{{ t('Policy Name') }}</FieldLabel>
<FieldContent>
<Input id="policy-name" v-model="form.name" autocomplete="off" />
</FieldContent>
<FieldDescription v-if="errors.name" class="text-destructive">
{{ errors.name }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label for="policy-content">{{ t('Policy Original') }}</Label>
<div class="max-h-[60vh] overflow-auto rounded-md border">
<json-editor id="policy-content" v-model="form.content" />
</div>
<p v-if="errors.content" class="text-sm text-destructive">{{ errors.content }}</p>
</div>
<Field>
<FieldLabel for="policy-content">{{ t('Policy Original') }}</FieldLabel>
<FieldContent>
<div class="max-h-[60vh] overflow-auto rounded-md border">
<json-editor id="policy-content" v-model="form.content" />
</div>
</FieldContent>
<FieldDescription v-if="errors.content" class="text-destructive">
{{ errors.content }}
</FieldDescription>
</Field>
</div>
<template #footer>
@@ -142,5 +150,5 @@ const submitForm = async () => {
<Button variant="default" :loading="submitting" @click="submitForm">{{ t('Submit') }}</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
+27 -6
View File
@@ -15,11 +15,18 @@
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel v-if="dialog.negativeText" @click.prevent="() => handleNegative(dialog)">
{{ dialog.negativeText }}
<AlertDialogCancel v-if="dialog.negativeText" as-child @click.prevent="() => handleNegative(dialog)">
<Button
:variant="negativeButtonVariant(dialog)"
class="w-full sm:w-auto text-foreground"
>
{{ dialog.negativeText }}
</Button>
</AlertDialogCancel>
<AlertDialogAction :class="positiveButtonClass(dialog)" @click.prevent="() => handlePositive(dialog)">
{{ dialog.positiveText || 'Confirm' }}
<AlertDialogAction as-child @click.prevent="() => handlePositive(dialog)">
<Button :class="positiveButtonClass(dialog)">
{{ dialog.positiveText || 'Confirm' }}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -40,6 +47,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { buttonVariants } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
import type { ButtonVariants } from '@/components/ui/button'
import type { DialogInstance } from '@/lib/ui/dialog'
import { useDialogController } from '@/lib/ui/dialog'
import { cn } from '@/lib/utils'
@@ -48,10 +57,22 @@ import { computed } from 'vue'
const controller = useDialogController()
const dialogs = computed(() => controller.dialogs.value)
const negativeButtonVariant = (dialog: DialogInstance): ButtonVariants['variant'] => {
return 'outline'
}
const positiveButtonClass = (dialog: DialogInstance) => {
const variant =
dialog.tone === 'destructive'
? 'destructive'
: dialog.tone === 'warning'
? 'secondary'
: 'default'
return cn(
buttonVariants({ variant: dialog.tone === 'destructive' || dialog.tone === 'warning' ? 'destructive' : 'default' }),
'w-full sm:w-auto'
buttonVariants({ variant }),
'w-full sm:w-auto',
variant === 'destructive' && 'text-white'
)
}
+1 -1
View File
@@ -2,7 +2,7 @@
<slot />
<AppDialogHost />
<ClientOnly>
<Toaster position="top-right" :rich-colors="true" :close-button="true" />
<Toaster position="top-center" :rich-colors="true" :close-button="true" />
</ClientOnly>
</template>
+80 -57
View File
@@ -1,5 +1,5 @@
<template>
<AppModal
<Modal
v-model="visible"
:title="t('Add Replication Rule') + ` (${t('Bucket')}: ${bucketName})`"
size="xl"
@@ -8,48 +8,66 @@
<div class="space-y-6">
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label>{{ t('Priority') }}</Label>
<Input v-model="formData.level" type="number" min="1" />
</div>
<div class="grid gap-2">
<Label>{{ t('Mode') }}</Label>
<AppSelect v-model="formData.modeType" :options="modeOptions" />
</div>
<div class="grid gap-2">
<Label>{{ t('Endpoint') }}</Label>
<Input v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Bucket') }}</Label>
<Input v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Access Key') }}</Label>
<Input v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</div>
<div class="grid gap-2">
<Label>{{ t('Secret Key') }}</Label>
<Input v-model="formData.secrretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Region') }}</Label>
<Input v-model="formData.region" :placeholder="t('Please enter region')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Storage Class') }}</Label>
<Input v-model="formData.storageType" :placeholder="t('Please enter storage class')" />
</div>
<Field>
<FieldLabel>{{ t('Priority') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.level" type="number" min="1" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Mode') }}</FieldLabel>
<FieldContent>
<Selector v-model="formData.modeType" :options="modeOptions" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Endpoint') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Bucket') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Secret Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.secrretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Region') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.region" :placeholder="t('Please enter region')" />
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Storage Class') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.storageType" :placeholder="t('Please enter storage class')" />
</FieldContent>
</Field>
</div>
<div class="grid gap-2">
<Label>{{ t('Prefix') }}</Label>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<Field>
<FieldLabel>{{ t('Prefix') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</FieldContent>
</Field>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
<FieldLabel class="text-sm font-medium">{{ t('Tags') }}</FieldLabel>
<Button variant="outline" size="sm" @click="addTag">
<Icon name="ri:add-line" class="size-4" />
{{ t('Add Tag') }}
@@ -105,22 +123,26 @@
</div>
<div class="space-y-3" v-if="formData.modeType === 'async'">
<div class="grid gap-2">
<Label>{{ t('Health Check Interval (seconds)') }}</Label>
<Input
v-model="formData.timecheck"
type="number"
min="1"
class="w-32"
/>
</div>
<div class="grid gap-2">
<Label>{{ t('Bandwidth Limit') }}</Label>
<div class="flex items-center gap-2">
<Input v-model="formData.bandwidth" type="number" min="0" class="w-32" />
<AppSelect v-model="formData.unit" :options="unitOptions" class="w-28" />
</div>
</div>
<Field>
<FieldLabel>{{ t('Health Check Interval (seconds)') }}</FieldLabel>
<FieldContent>
<Input
v-model="formData.timecheck"
type="number"
min="1"
class="w-32"
/>
</FieldContent>
</Field>
<Field>
<FieldLabel>{{ t('Bandwidth Limit') }}</FieldLabel>
<FieldContent>
<div class="flex items-center gap-2">
<Input v-model="formData.bandwidth" type="number" min="0" class="w-32" />
<Selector v-model="formData.unit" :options="unitOptions" class="w-28" />
</div>
</FieldContent>
</Field>
</div>
</div>
</div>
@@ -131,7 +153,7 @@
<Button variant="default" :loading="submitting" @click="handleSave">{{ t('Save') }}</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
@@ -139,8 +161,9 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppModal, AppSelect } from '@/components/app'
import { Label } from '@/components/ui/label'
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 { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { Icon } from '#components'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
import { computed, useAttrs } from 'vue'
const modelValue = defineModel<string>({ default: '' })
const props = withDefaults(
defineProps<{
placeholder?: string
icon?: string
groupClass?: HTMLAttributes['class']
inputClass?: HTMLAttributes['class']
addonClass?: HTMLAttributes['class']
clearable?: boolean
}>(),
{
placeholder: '',
icon: 'ri:search-line',
groupClass: undefined,
inputClass: undefined,
addonClass: undefined,
clearable: false,
}
)
const attrs = useAttrs()
const showClear = computed(() => props.clearable && Boolean(modelValue.value))
const handleClear = () => {
modelValue.value = ''
}
</script>
<template>
<InputGroup v-bind="attrs" :class="cn('w-full', groupClass)">
<InputGroupAddon :class="cn('text-muted-foreground', addonClass)">
<Icon :name="icon" class="size-4" />
</InputGroupAddon>
<InputGroupInput v-model="modelValue" :placeholder="placeholder" :class="inputClass" />
<InputGroupButton v-if="showClear" variant="ghost" size="icon-xs" aria-label="Clear" @click="handleClear">
<Icon name="ri:close-line" class="size-4" />
</InputGroupButton>
<slot />
</InputGroup>
</template>
+21 -29
View File
@@ -1,20 +1,12 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="w-full justify-start gap-2 px-2 transition-[padding] duration-200 group-data-[collapsible=icon]:h-9 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"
>
<Button variant="ghost">
<Icon :name="themeIcon" class="h-4 w-4 shrink-0" />
<span class="truncate group-data-[collapsible=icon]:hidden">{{ t(themeName) }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40" align="start">
<DropdownMenuItem
v-for="option in themeOptions"
:key="option.key"
@select="() => handleSelect(option.key)"
>
<DropdownMenuItem v-for="option in themeOptions" :key="option.key" @select="() => handleSelect(option.key)">
<Icon :name="option.icon" class="mr-2 h-4 w-4" />
{{ option.label }}
</DropdownMenuItem>
@@ -23,52 +15,52 @@
</template>
<script setup lang="ts">
import { Icon } from '#components';
import { useColorMode } from '@vueuse/core';
import { Button } from '@/components/ui/button';
import { Icon } from '#components'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
} from '@/components/ui/dropdown-menu'
import { useColorMode } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n();
const { store } = useColorMode();
const { t } = useI18n()
const { store } = useColorMode()
const themeName = computed(() => {
switch (store.value) {
case 'dark':
return 'Dark';
return 'Dark'
case 'light':
return 'Light';
return 'Light'
default:
return 'Auto';
return 'Auto'
}
});
})
const themeIcon = computed(() => {
switch (store.value) {
case 'dark':
return 'ri:moon-fill';
return 'ri:moon-fill'
case 'light':
return 'ri:sun-fill';
return 'ri:sun-fill'
default:
return 'ri:contrast-2-line';
return 'ri:contrast-2-line'
}
});
})
const themeOptions = computed(() => [
{ label: t('Light'), key: 'light', icon: 'ri:sun-fill' },
{ label: t('Dark'), key: 'dark', icon: 'ri:moon-fill' },
{ label: t('Auto'), key: 'auto', icon: 'ri:contrast-2-line' },
]);
])
const handleSelect = (key: string) => {
if (key === 'light' || key === 'dark' || key === 'auto') {
store.value = key;
store.value = key
}
};
}
</script>
+16 -12
View File
@@ -1,20 +1,24 @@
<template>
<AppModal
<Modal
v-model="visibleProxy"
:title="t('Update Key') + '' + nameProxy"
size="md"
:close-on-backdrop="false"
>
<div class="space-y-4">
<div class="grid gap-2">
<Label>{{ t('Access Key') }}</Label>
<Input v-model="formModel.accessKey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</div>
<Field>
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formModel.accessKey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Secret Key') }}</Label>
<Input v-model="formModel.secretKey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</div>
<Field>
<FieldLabel>{{ t('Secret Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formModel.secretKey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</FieldContent>
</Field>
</div>
<template #footer>
@@ -23,15 +27,15 @@
<Button variant="default" :loading="submitting" @click="submitForm">{{ t('Submit') }}</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { AppModal } from '@/components/app'
import { Label } from '@/components/ui/label'
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'
+62 -54
View File
@@ -1,14 +1,9 @@
<template>
<AppModal v-model="visible" :title="t('Add Tier')" size="lg" :close-on-backdrop="false">
<Modal v-model="visible" :title="t('Add Tier')" size="lg" :close-on-backdrop="false">
<div class="space-y-6">
<div v-if="!formData.type" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="item in typeOptions"
:key="item.value"
class="cursor-pointer border border-border/70 transition hover:border-primary"
@click="chooseType(item.value)"
>
<div class="flex items-center gap-3">
<div v-for="item in typeOptions" :key="item.value" class="cursor-pointer border border-border/70 transition hover:border-primary" @click="chooseType(item.value)">
<div class="flex items-center gap-3 p-4">
<img :src="item.iconUrl" class="h-10 w-10" alt="" />
<div>
<p class="text-base font-semibold">{{ item.label }}</p>
@@ -20,7 +15,7 @@
<div v-else class="space-y-5">
<div class="cursor-pointer border transition hover:border-primary" @click="resetType">
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 p-4">
<img :src="iconUrl" class="h-10 w-10" alt="" />
<div>
<p class="text-sm text-muted-foreground">{{ t('Selected Type') }}</p>
@@ -30,51 +25,64 @@
</div>
<div class="space-y-4">
<div class="grid gap-2">
<Label>{{ t('Name') }} (A-Z,0-9,_)</Label>
<Input
v-model="formData.name"
:placeholder="t('Please enter name')"
autocomplete="off"
@input="filterName"
/>
<p v-if="errors.name" class="text-sm text-destructive">{{ errors.name }}</p>
</div>
<Field>
<FieldLabel>{{ t('Name') }} (A-Z,0-9,_)</FieldLabel>
<FieldContent>
<Input v-model="formData.name" :placeholder="t('Please enter name')" autocomplete="off" @input="filterName" />
</FieldContent>
<FieldDescription v-if="errors.name" class="text-destructive">
{{ errors.name }}
</FieldDescription>
</Field>
<div class="grid gap-2">
<Label>{{ t('Endpoint') }}</Label>
<Input v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
</div>
<Field>
<FieldLabel>{{ t('Endpoint') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Access Key') }}</Label>
<Input v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</div>
<Field>
<FieldLabel>{{ t('Access Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Secret Key') }}</Label>
<Input v-model="formData.secretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</div>
<Field>
<FieldLabel>{{ t('Secret Key') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.secretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Bucket') }}</Label>
<Input v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
</div>
<Field>
<FieldLabel>{{ t('Bucket') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Prefix') }}</Label>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<Field>
<FieldLabel>{{ t('Prefix') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Region') }}</Label>
<Input v-model="formData.region" :placeholder="t('Please enter region')" />
</div>
<Field>
<FieldLabel>{{ t('Region') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.region" :placeholder="t('Please enter region')" />
</FieldContent>
</Field>
<div class="grid gap-2">
<Label>{{ t('Storage Class') }}</Label>
<Input v-model="formData.storageclass" :placeholder="t('Please Enter storage class')" />
</div>
<Field>
<FieldLabel>{{ t('Storage Class') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.storageclass" :placeholder="t('Please Enter storage class')" />
</FieldContent>
</Field>
</div>
</div>
</div>
@@ -87,20 +95,20 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AppModal } from '@/components/app'
import { Label } from '@/components/ui/label'
import { computed, reactive, ref } from 'vue'
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 MinioIcon from '~/assets/svg/minio.svg'
import AWSIcon from '~/assets/svg/aws.svg'
import RustfsIcon from '~/assets/logo.svg'
import AWSIcon from '~/assets/svg/aws.svg'
import MinioIcon from '~/assets/svg/minio.svg'
const { t } = useI18n()
const usetier = useTiers()
+1 -1
View File
@@ -12,7 +12,7 @@ defineProps<{
</script>
<template>
<Card class="text-sm">
<Card class="text-sm shadow-none">
<CardHeader v-if="title" class="p-3 border-b">
<CardTitle>
{{ title }}
+2 -4
View File
@@ -1,13 +1,11 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost"
class="w-full items-center justify-between gap-2 rounded-none px-4 py-3 text-left transition-[padding] duration-200 group-data-[collapsible=icon]:h-12 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
<Button variant="ghost">
<div class="flex items-center gap-3">
<span class="flex h-9 w-9 items-center justify-center rounded-full border bg-muted">
<span class="flex h-8 w-8 items-center justify-center rounded-full border bg-muted">
<img src="~/assets/img/rustfs.png" alt="RustFS" class="h-8 w-8 rounded-full object-cover" />
</span>
<span v-if="!isCollapsed" class="text-sm font-medium">{{ t('RustFS') }}</span>
</div>
<Icon v-if="!isCollapsed" name="ri:more-2-line" class="h-4 w-4 text-muted-foreground" />
</Button>
+3 -3
View File
@@ -1,5 +1,5 @@
<template>
<AppModal v-model="visible" :title="group.name || t('Members')" size="lg" :close-on-backdrop="false">
<Modal v-model="visible" :title="group.name || t('Members')" size="lg" :close-on-backdrop="false">
<div class="space-y-4">
<div class="flex items-center justify-between rounded-md border px-3 py-2">
<span class="text-sm text-muted-foreground">{{ t('Status') }}</span>
@@ -20,11 +20,11 @@
</TabsContent>
</Tabs>
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { AppModal } from '@/components/app'
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'
+2 -3
View File
@@ -1,10 +1,10 @@
<template>
<div class="space-y-4">
<Card>
<Card class="shadow-none">
<CardContent class="space-y-4 pt-6">
<div v-if="!editStatus" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full sm:max-w-xs">
<Input v-model="searchTerm" :placeholder="t('Search User')" />
<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">
<Icon class="size-4" name="ri:add-line" />
@@ -95,7 +95,6 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+3 -3
View File
@@ -2,7 +2,7 @@
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { AppModal } from '@/components/app'
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'
@@ -104,7 +104,7 @@ const submitForm = async () => {
</script>
<template>
<AppModal v-model="modalVisible" :title="t('Add group members')" size="lg" :close-on-backdrop="false">
<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>
@@ -171,5 +171,5 @@ const submitForm = async () => {
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
+1 -3
View File
@@ -2,7 +2,7 @@
<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">
<Input v-model="searchTerm" :placeholder="t('Search Policy')" />
<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">
<Icon class="size-4" name="ri:add-line" />
@@ -88,8 +88,6 @@
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -1,9 +1,9 @@
<template>
<AppModal v-model="visible" :title="t('Batch allocation policies')" size="xl" :close-on-backdrop="false">
<Modal v-model="visible" :title="t('Batch allocation policies')" size="xl" :close-on-backdrop="false">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full sm:max-w-xs">
<Input v-model="searchTerm" :placeholder="t('Search Policy')" />
<SearchInput v-model="searchTerm" :placeholder="t('Search Policy')" clearable class="w-full" />
</div>
<Button variant="secondary" :disabled="!checkedKeys.length || submitting" @click="changePolicies">
{{ t('Submit') }}
@@ -14,10 +14,10 @@
<TableHeader>
<TableRow>
<TableHead class="w-12">
<AppCheckbox
<Checkbox
:checked="allVisibleSelected"
:indeterminate="checkedKeys.length > 0 && !allVisibleSelected"
@update:checked="toggleSelectAll"
@update:checked="(value: boolean | 'indeterminate') => toggleSelectAll(value === true)"
/>
</TableHead>
<TableHead>{{ t('Name') }}</TableHead>
@@ -26,9 +26,9 @@
<TableBody v-if="filteredPolicies.length">
<TableRow v-for="policy in filteredPolicies" :key="policy.name">
<TableCell>
<AppCheckbox
<Checkbox
:checked="isSelected(policy.name)"
@update:checked="(value: boolean) => toggleSelection(policy.name, value)"
@update:checked="(value: boolean | 'indeterminate') => toggleSelection(policy.name, value === true)"
/>
</TableCell>
<TableCell class="font-medium">{{ policy.name }}</TableCell>
@@ -43,14 +43,14 @@
</TableBody>
</Table>
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { AppCheckbox, AppModal } from '@/components/app'
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'
+1 -4
View File
@@ -1,9 +1,7 @@
<template>
<div>
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex w-full max-w-md items-center gap-2">
<Input v-model="searchTerm" :placeholder="t('Search User Group')" />
</div>
<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" />
@@ -97,7 +95,6 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+1 -4
View File
@@ -1,9 +1,7 @@
<template>
<div>
<div class="mb-4 mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex w-full max-w-md items-center gap-2">
<Input v-model="searchTerm" :placeholder="t('Search Access User')" />
</div>
<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"
@@ -110,7 +108,6 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+3 -3
View File
@@ -1,5 +1,5 @@
<template>
<AppModal v-model="visible" :title="user.accessKey || t('Account')" size="lg" :close-on-backdrop="false">
<Modal v-model="visible" :title="user.accessKey || t('Account')" size="lg" :close-on-backdrop="false">
<div class="space-y-4">
<div class="flex items-center justify-between rounded-md border px-3 py-2">
<span class="text-sm text-muted-foreground">{{ t('Status') }}</span>
@@ -26,11 +26,11 @@
<users-user-notice ref="noticeRef" @search="refreshUser" />
</div>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { AppModal } from '@/components/app'
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'
+3 -3
View File
@@ -4,7 +4,7 @@
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full sm:max-w-xs">
<Input v-model="searchTerm" :placeholder="t('Search Account')" />
<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">
<Icon class="size-4" name="ri:add-line" />
@@ -73,7 +73,7 @@
</div>
<div class="space-y-2">
<Label>{{ t('Expiration') }}</Label>
<AppDateTimePicker v-model="formModel.expiry" :min="minExpiry" :placeholder="t('Please select expiration date')" />
<DateTimePicker v-model="formModel.expiry" :min="minExpiry" :placeholder="t('Please select expiration date')" />
</div>
<div class="space-y-2">
<Label>{{ t('Name') }}</Label>
@@ -122,7 +122,7 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppDateTimePicker } from '@/components/app'
import DateTimePicker from '@/components/datetime-picker.vue'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
+2 -2
View File
@@ -8,7 +8,7 @@
<div class="space-y-2">
<Label>{{ t('Expiration') }}</Label>
<AppDateTimePicker
<DateTimePicker
v-model="formModel.expiry"
:min="minExpiry"
:placeholder="t('Please select expiration date')"
@@ -69,7 +69,7 @@
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { AppDateTimePicker } from '@/components/app'
import DateTimePicker from '@/components/datetime-picker.vue'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import dayjs from 'dayjs'
+1 -3
View File
@@ -2,7 +2,7 @@
<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">
<Input v-model="searchTerm" :placeholder="t('Search Group')" />
<SearchInput v-model="searchTerm" :placeholder="t('Search Group')" clearable class="w-full" />
</div>
<Button variant="secondary" class="inline-flex items-center gap-2" @click="startEditing">
<Icon class="size-4" name="ri:add-line" />
@@ -88,8 +88,6 @@
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+3 -3
View File
@@ -1,5 +1,5 @@
<template>
<AppModal v-model="visible" :title="t('Create User')" size="lg" :close-on-backdrop="false">
<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">
@@ -115,14 +115,14 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { AppModal } from '@/components/app'
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'
+3 -3
View File
@@ -1,5 +1,5 @@
<template>
<AppModal v-model="visible" :title="t('New user has been created')" size="md" :close-on-backdrop="false">
<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>
@@ -17,13 +17,13 @@
<Button variant="default" @click="exportFile">{{ t('Export') }}</Button>
</div>
</template>
</AppModal>
</Modal>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { AppModal } from '@/components/app'
import Modal from '@/components/modal.vue'
import { Label } from '@/components/ui/label'
import { download } from '@/utils/export-file'
import { ref } from 'vue'
+1 -3
View File
@@ -2,7 +2,7 @@
<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">
<Input v-model="searchTerm" :placeholder="t('Search Policy')" />
<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">
<Icon class="size-4" name="ri:add-line" />
@@ -88,8 +88,6 @@
</template>
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+6 -7
View File
@@ -23,7 +23,7 @@ Each phase will be committed independently on `refactor/shadcn-vue` to keep roll
| 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) and `AppTag` (using `Badge` or `Chip`). Use `Carousel` block already present. |
| 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. |
@@ -34,13 +34,12 @@ Each phase will be committed independently on `refactor/shadcn-vue` to keep roll
## Shared Components To Introduce
- `components/app/AppUiProvider.vue` wraps shadcn theme providers and exports composables for toasts/dialogs.
- `components/app/AppSidebar.vue` + related items (menu, user dropdown host) following Sidebar07 markup.
- `components/app/AppCard.vue`, `AppTag.vue` (buttons now import `Button` directly).
- `components/app/AppForm.vue`, `AppFormField.vue`, `AppFieldGrid.vue` for consistent form layout.
- `components/app/AppDialog.vue`, `AppDrawer.vue`, `AppConfirmDialog.vue`.
- `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.
- `components/app/AppDescriptionList.vue`, `AppEmpty.vue`, `AppStatistic.vue`, `AppSpinner.vue`.
- 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.
+4 -4
View File
@@ -1,10 +1,10 @@
# ShadCN Vue Migration Todo
- [x] Replace global layout shell (`layouts/default.vue`) to use `components/app/AppSidebar` built with ShadCN Sidebar primitives and update the layout structure similar to Sidebar07.
- [x] Build `components/app/AppSidebar.vue` and supporting subcomponents to render navigation, language/theme controls, and user menu with ShadCN UI widgets.
- [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/app-` when helpful.
- [x] Establish shared wrappers (`AppCard`, `AppSelect`, etc.) under `components/app`; buttons/inputs now import shadcn primitives directly.
- [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.
+975 -805
View File
File diff suppressed because it is too large Load Diff
+977 -814
View File
File diff suppressed because it is too large Load Diff
+976 -812
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import AppSidebar from '@/components/app-sidebar.vue'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppSidebar from '~/components/app-sidebar.vue'
const route = useRoute()
const showSidebar = computed(() => !route.path.startsWith('/auth'))
+25 -30
View File
@@ -1,29 +1,22 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Access Keys') }}</h1>
<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" />
<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">
<Icon name="ri:delete-bin-5-line" class="size-4" />
<span>{{ t('Delete Selected') }}</span>
</Button>
<Button variant="secondary" @click="addItem">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Access Key') }}</span>
</Button>
</template>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between w-full">
<div class="flex w-full max-w-sm items-center gap-2">
<Icon name="ri:search-line" class="size-4 text-muted-foreground" />
<Input v-model="searchTerm" :placeholder="t('Search Access Key')" />
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" @click="changePasswordVisible = true">
<Icon name="ri:key-2-line" class="size-4" />
<span>{{ t('Change Password') }}</span>
</Button>
<Button variant="outline" :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">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Access Key') }}</span>
</Button>
</div>
</div>
</page-header>
<div class="space-y-3">
@@ -41,19 +34,18 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { AppTag } from '@/components/app'
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'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import type { ColumnDef } from '@tanstack/vue-table'
import dayjs from 'dayjs'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChangePassword, EditItem, NewItem } from '~/components/access-keys'
import { useDataTable } from '~/components/data-table'
import DataTablePagination from '@/components/data-table/data-table-pagination.vue'
import DataTable from '@/components/data-table/data-table.vue'
const { t } = useI18n()
const dialog = useDialog()
@@ -124,8 +116,11 @@ const columns: ColumnDef<RowData>[] = [
accessorKey: 'accountStatus',
header: () => t('Status'),
cell: ({ row }) =>
h(AppTag, { tone: row.original.accountStatus === 'on' ? 'success' : 'danger' }, () =>
row.original.accountStatus === 'on' ? t('Available') : t('Disabled')),
h(
Badge,
{ variant: row.original.accountStatus === 'on' ? 'secondary' : 'destructive' },
() => (row.original.accountStatus === 'on' ? t('Available') : t('Disabled'))
),
},
{
accessorKey: 'name',
+34 -25
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
await setPageLayout('plain')
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -75,35 +76,43 @@ const handleLogin = async () => {
<form @submit.prevent="handleLogin" autocomplete="off">
<div class="grid gap-y-6">
<template v-if="method == 'accessKeyAndSecretKey'">
<div>
<label for="accessKey" class="block text-sm mb-2 dark:text-white">{{ t('Account') }}</label>
<Input v-model="accessKeyAndSecretKey.accessKeyId" autocomplete="new-password" type="text" :placeholder="t('Please enter account')" />
</div>
<div>
<div class="flex justify-between items-center">
<label for="secretKey" class="block text-sm mb-2 dark:text-white">{{ t('Key') }}</label>
</div>
<Input v-model="accessKeyAndSecretKey.secretAccessKey" autocomplete="new-password" type="password" :placeholder="t('Please enter key')" />
</div>
<Field>
<FieldLabel for="accessKey">{{ t('Account') }}</FieldLabel>
<FieldContent>
<Input id="accessKey" v-model="accessKeyAndSecretKey.accessKeyId" autocomplete="new-password" type="text" :placeholder="t('Please enter account')" />
</FieldContent>
</Field>
<Field>
<FieldLabel for="secretKey">{{ t('Key') }}</FieldLabel>
<FieldContent>
<Input id="secretKey" v-model="accessKeyAndSecretKey.secretAccessKey" autocomplete="new-password" type="password" :placeholder="t('Please enter key')" />
</FieldContent>
</Field>
</template>
<template v-else>
<div>
<label for="accessKey" class="block text-sm mb-2 dark:text-white">{{ t('STS Username') }}</label>
<Input v-model="sts.accessKeyId" autocomplete="new-password" type="text" :placeholder="t('Please enter STS username')" />
</div>
<div>
<label for="sts.secretAccessKey" class="block text-sm mb-2 dark:text-white">
<Field>
<FieldLabel for="stsAccessKey">{{ t('STS Username') }}</FieldLabel>
<FieldContent>
<Input id="stsAccessKey" v-model="sts.accessKeyId" autocomplete="new-password" type="text" :placeholder="t('Please enter STS username')" />
</FieldContent>
</Field>
<Field>
<FieldLabel for="stsSecretKey">
{{ t('STS Key') }}
</label>
<Input v-model="sts.secretAccessKey" autocomplete="new-password" type="password" :placeholder="t('Please enter STS key')" />
</div>
<div>
<label for="sessionToken" class="block text-sm mb-2 dark:text-white">
</FieldLabel>
<FieldContent>
<Input id="stsSecretKey" v-model="sts.secretAccessKey" autocomplete="new-password" type="password" :placeholder="t('Please enter STS key')" />
</FieldContent>
</Field>
<Field>
<FieldLabel for="sessionToken">
{{ t('STS Session Token') }}
</label>
<Input v-model="sts.sessionToken" autocomplete="new-password" type="text" :placeholder="t('Please enter STS session token')" />
</div>
</FieldLabel>
<FieldContent>
<Input id="sessionToken" v-model="sts.sessionToken" autocomplete="new-password" type="text" :placeholder="t('Please enter STS session token')" />
</FieldContent>
</Field>
</template>
<Button type="submit" variant="default" class="w-full justify-center">
@@ -133,4 +142,4 @@ const handleLogin = async () => {
</div>
</div>
</div>
</template>
</template>
+4 -6
View File
@@ -1,12 +1,10 @@
<template>
<page>
<page-header>
<template #title>
<div class="flex items-center gap-4">
<h1 @click="$router.push(bucketPath())" class="cursor-pointer">{{ bucketName }}</h1>
<object-path-links :object-key="key" @click="path => $router.push(bucketPath(path))" />
</div>
</template>
<div class="flex items-center gap-4">
<h1 @click="$router.push(bucketPath())" class="cursor-pointer">{{ bucketName }}</h1>
<object-path-links :object-key="key" @click="path => $router.push(bucketPath(path))" />
</div>
</page-header>
<div class="flex flex-col gap-4">
<object-list v-if="isObjectList" :bucket="bucketName" :path="key" />
+13 -20
View File
@@ -1,24 +1,17 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Buckets') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Buckets') }}</h1>
<template #actions>
<div class="flex max-w-sm items-center gap-2">
<Icon name="ri:search-2-line" class="size-4 text-muted-foreground -mr-8" />
<Input v-model="searchTerm" :placeholder="t('Search')" class="pl-8" />
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="secondary" @click="formVisible = true">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Create Bucket') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
<SearchInput v-model="searchTerm" :placeholder="t('Search')" clearable class="max-w-sm" />
<Button variant="secondary" @click="formVisible = true">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Create Bucket') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</template>
</page-header>
@@ -33,13 +26,13 @@
import { Button } from '@/components/ui/button'
import { Icon, NuxtLink } from '#components'
import DataTable from '@/components/data-table/data-table.vue'
import { useDataTable } from '@/components/data-table/useDataTable'
import { niceBytes } from '@/utils/functions'
import type { ColumnDef } from '@tanstack/vue-table'
import dayjs from 'dayjs'
import { computed, h, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const message = useMessage()
@@ -133,7 +126,7 @@ const columns: ColumnDef<BucketRow>[] = [
header: () => t('Actions'),
enableSorting: false,
cell: ({ row }) =>
h('div', { class: 'flex justify-center gap-2' }, [
h('div', { class: 'flex items-center gap-2' }, [
h(
Button,
{
+3 -5
View File
@@ -1,11 +1,9 @@
<template>
<page>
<page-header>
<template #title>
<div class="flex items-center gap-4">
<h1 class="cursor-pointer">{{ bucketName }}</h1>
</div>
</template>
<div class="flex items-center gap-4">
<h1 class="cursor-pointer">{{ bucketName }}</h1>
</div>
</page-header>
<div class="flex flex-col gap-4">
<buckets-info :bucket="bucketName" />
+11 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
await setPageLayout('plain')
@@ -114,16 +115,18 @@ onMounted(() => {
<!-- Form -->
<form @submit.prevent="validateAndSave" autocomplete="off">
<div class="grid gap-y-6">
<div>
<label for="serverHost" class="block text-sm mb-2 dark:text-white">{{ t('Server Address') }}</label>
<div class="text-xs text-gray-500 mb-2">
<Field>
<FieldLabel for="serverHost">{{ t('Server Address') }}</FieldLabel>
<FieldDescription>
{{ t('Leave empty to use current host as default') }}
</div>
<Input v-model="serverHost" type="text" :placeholder="t('Please enter server address (e.g., http://localhost:9000)')" />
<div class="text-xs text-gray-500 mt-1">
</FieldDescription>
<FieldContent>
<Input id="serverHost" v-model="serverHost" type="text" :placeholder="t('Please enter server address (e.g., http://localhost:9000)')" />
</FieldContent>
<FieldDescription>
{{ t('Example: http://localhost:9000 or https://your-domain.com') }}
</div>
</div>
</FieldDescription>
</Field>
<div class="flex gap-3">
<Button type="submit" class="flex-1">
+18 -24
View File
@@ -1,25 +1,20 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Event Destinations') }}</h1>
<h1 class="text-2xl font-bold">{{ t('Event Destinations') }}</h1>
<template #actions>
<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">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Event Destination') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</template>
<div class="flex flex-col gap-4 w-full md:flex-row md:items-center md:justify-between">
<div class="flex w-full max-w-sm items-center gap-2">
<Icon name="ri:search-2-line" class="size-4 text-muted-foreground" />
<Input v-model="searchTerm" :placeholder="t('Search')" />
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button variant="secondary" @click="addForm">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Event Destination') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
</div>
</page-header>
<DataTable :table="table" :is-loading="pending" :empty-title="t('No Destinations')" :empty-description="t('Create an event destination to forward notifications.')" />
@@ -30,15 +25,14 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import { AppTag } from '@/components/app'
import DataTable from '@/components/data-table/data-table.vue'
import { useDataTable } from '@/components/data-table/useDataTable'
import { Badge } from '@/components/ui/badge'
import type { ColumnDef } from '@tanstack/vue-table'
import { h, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const message = useMessage()
@@ -70,9 +64,9 @@ const columns: ColumnDef<RowData>[] = [
header: () => t('Status'),
cell: ({ row }) =>
h(
AppTag,
Badge,
{
tone: row.original.status === 'enable' ? 'success' : 'warning',
variant: row.original.status === 'enable' ? 'secondary' : 'outline',
},
() => (row.original.status === 'enable' ? t('Enabled') : row.original.status || '-')
),
+25 -25
View File
@@ -1,31 +1,31 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Events') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Events') }}</h1>
<template #actions>
<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 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>
</template>
</page-header>
@@ -79,7 +79,7 @@
</Table>
</div>
<Card v-else class="relative">
<Card v-else class="relative shadow-none">
<CardContent class="py-16">
<Empty class="mx-auto max-w-sm text-center">
<EmptyHeader>
+80 -90
View File
@@ -1,9 +1,7 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Import/Export') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Import/Export') }}</h1>
</page-header>
<div class="flex flex-col gap-6">
@@ -19,36 +17,23 @@
</TabsTrigger>
</TabsList>
<TabsContent value="iam" class="mt-0">
<div class="space-y-6">
<div class="space-y-6">
<div class="space-y-2">
<h2 class="text-lg font-semibold">{{ t('IAM Configuration Export') }}</h2>
<p class="text-sm text-muted-foreground">
{{
t(
'Export all IAM configurations including users, groups, policies, and access keys in a ZIP file.'
)
}}
</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Icon name="ri:user-line" class="text-blue-500" />
<span>{{ t('Users') }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Icon name="ri:group-line" class="text-green-500" />
<span>{{ t('User Groups') }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Icon name="ri:shield-line" class="text-purple-500" />
<span>{{ t('IAM Policies') }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Icon name="ri:key-line" class="text-orange-500" />
<span>{{ t('AK/SK') }}</span>
<TabsContent value="export" class="mt-0">
<Card class="shadow-none">
<CardHeader class="space-y-1">
<CardTitle>{{ t('IAM Configuration Export') }}</CardTitle>
<CardDescription>
{{
t(
'Export all IAM configurations including users, groups, policies, and access keys in a ZIP file.'
)
}}
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div v-for="item in exportHighlights" :key="item.label" class="flex items-center gap-3 rounded-md border bg-muted/40 p-3">
<Icon :name="item.icon" :class="item.iconClass" class="size-5" />
<span class="text-sm font-medium text-foreground">{{ t(item.label) }}</span>
</div>
</div>
@@ -59,71 +44,66 @@
{{ t('The exported file contains sensitive information. Please keep it secure.') }}
</AlertDescription>
</Alert>
</CardContent>
<CardFooter class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-muted-foreground">
{{ t('Download complete IAM configuration as ZIP file') }}
</div>
<Button variant="default" size="lg" :loading="isLoading" :disabled="isLoading" @click="handleExportIam">
<Icon name="ri:download-2-line" class="size-4" />
<span>{{ isLoading ? t('Exporting...') : t('Export Now') }}</span>
</Button>
</CardFooter>
</Card>
</TabsContent>
<div class="flex flex-col gap-2 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-sm font-semibold">{{ t('Export IAM Configuration') }}</h3>
<p class="text-xs text-muted-foreground">
{{ t('Download complete IAM configuration as ZIP file') }}
<TabsContent value="import" class="mt-0">
<Card class="shadow-none">
<CardHeader class="space-y-1">
<CardTitle>{{ t('IAM Configuration Import') }}</CardTitle>
<CardDescription>
{{ t('Import IAM configurations from a previously exported ZIP file.') }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-3">
<UploadZone :accept="'.zip'" :disabled="isLoading" class="border-dashed" @change="handleFileSelect">
<p class="text-base font-medium">{{ t('Click or drag ZIP file to this area to upload') }}</p>
<p class="text-sm text-muted-foreground">
{{ t('Only ZIP files are supported, and file size should not exceed 10MB') }}
</p>
</div>
<Button variant="default" size="lg" :loading="isLoading" :disabled="isLoading" @click="handleExportIam">
<Icon name="ri:download-2-line" class="size-4" />
<span>{{ isLoading ? t('Exporting...') : t('Export Now') }}</span>
</Button>
</div>
</div>
</UploadZone>
<p v-if="uploadError" class="text-sm text-destructive">{{ uploadError }}</p>
<div class="space-y-6">
<div class="space-y-2">
<h2 class="text-lg font-semibold">{{ t('IAM Configuration Import') }}</h2>
<p class="text-sm text-muted-foreground">
{{ t('Import IAM configurations from a previously exported ZIP file.') }}
</p>
</div>
<div class="space-y-4">
<div class="space-y-3">
<h3 class="text-sm font-medium">{{ t('Select ZIP File') }}</h3>
<AppUploadZone :accept="'.zip'" :disabled="isLoading" @change="handleFileSelect">
<p class="text-base font-medium">{{ t('Click or drag ZIP file to this area to upload') }}</p>
<p class="text-sm text-muted-foreground">
{{ t('Only ZIP files are supported, and file size should not exceed 10MB') }}
</p>
</AppUploadZone>
<p v-if="uploadError" class="text-sm text-destructive">{{ uploadError }}</p>
<div v-if="selectedFile" class="flex items-center justify-between rounded-md border p-3">
<Card v-if="selectedFile" class="border-dashed bg-muted/30 shadow-none">
<CardContent class="flex items-start justify-between gap-3 py-4">
<div>
<p class="text-sm font-medium">{{ selectedFile.name }}</p>
<p class="text-sm font-medium text-foreground">{{ selectedFile.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatSize(selectedFile.size) }}
</p>
</div>
<Button variant="ghost" size="sm" class="text-destructive" @click="clearSelectedFile">
<Button variant="ghost" size="icon-sm" class="text-destructive" @click="clearSelectedFile">
<Icon name="ri:close-line" class="size-4" />
</Button>
</div>
</div>
<div class="flex flex-col gap-2 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-sm font-semibold">{{ t('Import IAM Configuration') }}</h3>
<p class="text-xs text-muted-foreground">
{{
selectedFile
? t('Ready to import: {filename}', { filename: selectedFile.name })
: t('Please select a ZIP file to import')
}}
</p>
</div>
<Button variant="default" size="lg" :loading="isLoading" :disabled="isLoading || !selectedFile" @click="handleImportIam">
<Icon name="ri:upload-2-line" class="size-4" />
<span>{{ isLoading ? t('Importing...') : t('Import Now') }}</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</CardContent>
<CardFooter class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-muted-foreground">
{{
selectedFile
? t('Ready to import: {filename}', { filename: selectedFile.name })
: t('Please select a ZIP file to import')
}}
</div>
<Button variant="default" size="lg" :loading="isLoading" :disabled="isLoading || !selectedFile" @click="handleImportIam">
<Icon name="ri:upload-2-line" class="size-4" />
<span>{{ isLoading ? t('Importing...') : t('Import Now') }}</span>
</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
@@ -134,8 +114,9 @@
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppUploadZone } from '@/components/app'
import UploadZone from '@/components/upload-zone.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -149,8 +130,17 @@ definePageMeta({
const { isLoading, exportIamConfig, importIamConfig } = useImportExport()
const activeTab = ref('iam')
const tabs = computed(() => [{ key: 'iam', label: t('IAM') }])
const activeTab = ref<'export' | 'import'>('export')
const tabs = computed(() => [
{ key: 'export', label: t('Export') },
{ key: 'import', label: t('Import') },
])
const exportHighlights = computed(() => [
{ label: 'Users', icon: 'ri:user-line', iconClass: 'text-blue-500' },
{ label: 'User Groups', icon: 'ri:group-line', iconClass: 'text-green-500' },
{ label: 'IAM Policies', icon: 'ri:shield-line', iconClass: 'text-purple-500' },
{ label: 'AK/SK', icon: 'ri:key-line', iconClass: 'text-orange-500' },
])
const selectedFile = ref<File | null>(null)
const uploadError = ref('')
+66 -58
View File
@@ -1,59 +1,84 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">KMS API Test Page</h1>
</template>
<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>
<AppCard title="KMS API Test Suite" class="mb-6" content-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 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>
<AppCard v-if="testResults.length > 0" title="Test Results" content-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>
<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>
<AppTag :tone="result.success ? 'success' : 'danger'" class="self-start">
{{ result.success ? 'PASS' : 'FAIL' }}
</AppTag>
</div>
</div>
</AppCard>
</CardContent>
</Card>
<AppCard title="API Documentation">
<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>
</AppCard>
</AppCard>
<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 { AppCard, AppTag } from '@/components/app'
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'
@@ -61,7 +86,6 @@ import { reactive, ref } from 'vue'
const { getKMSStatus, getConfiguration, getKeyList, clearCache } = useSSE()
const message = useMessage()
// Test states
const testing = reactive({
status: false,
config: false,
@@ -69,26 +93,10 @@ const testing = reactive({
cache: false,
})
const testResults = ref<
Array<{
test: string
success: boolean
message: string
data?: any
timestamp: Date
}>
>([])
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(),
})
// Keep only last 10 results
testResults.value.unshift({ test, success, message, data, timestamp: new Date() })
if (testResults.value.length > 10) {
testResults.value = testResults.value.slice(0, 10)
}
@@ -145,7 +153,7 @@ const testClearCache = async () => {
'Clear Cache',
result.status === 'success',
`Cache clear result: ${result.status} - ${result.message}`,
result
result,
)
message.success('Clear cache test completed')
} catch (error) {
+15 -15
View File
@@ -1,31 +1,31 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('License') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('License') }}</h1>
</page-header>
<div>
<AppCard class="space-y-4">
<div class="text-center">
<h2 class="text-2xl font-bold">{{ t('Apache License') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">
{{ t('Version 2.0, January 2004') }}
</p>
</div>
<ScrollArea class="h-[70vh] rounded-lg border">
<pre class="whitespace-pre-wrap p-6 text-sm leading-6 text-muted-foreground">
<Card class="space-y-4 shadow-none">
<CardContent class="space-y-4">
<div class="text-center">
<h2 class="text-2xl font-bold">{{ t('Apache License') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">
{{ t('Version 2.0, January 2004') }}
</p>
</div>
<ScrollArea class="h-[70vh] rounded-lg border">
<pre class="whitespace-pre-wrap p-6 text-sm leading-6 text-muted-foreground">
{{ licenseContent }}
</pre>
</ScrollArea>
</AppCard>
</ScrollArea>
</CardContent>
</Card>
</div>
</page>
</template>
<script setup lang="ts">
import { AppCard } from '@/components/app'
import { Card, CardContent } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useI18n } from 'vue-i18n'
import licenseText from '~/LICENSE?raw'
+87 -79
View File
@@ -1,96 +1,104 @@
<template>
<div v-if="hasLicense">
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Enterprise License') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Enterprise License') }}</h1>
</page-header>
<div class="space-y-6">
<AppCard class="space-y-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<Badge :variant="hasValidLicense ? 'default' : 'destructive'">
{{ t('Enterprise License') }}
</Badge>
<span :class="['text-sm font-medium', hasValidLicense ? 'text-emerald-600' : 'text-rose-500']">
{{ t('Status') }}{{ hasValidLicense ? t('Normal') : t('Expired') }}
</span>
<Card class="shadow-none">
<CardContent class="space-y-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<Badge :variant="hasValidLicense ? 'default' : 'destructive'">
{{ t('Enterprise License') }}
</Badge>
<span :class="['text-sm font-medium', hasValidLicense ? 'text-emerald-600' : 'text-rose-500']">
{{ t('Status') }}{{ hasValidLicense ? t('Normal') : t('Expired') }}
</span>
</div>
<p class="text-sm text-muted-foreground">
{{ t('License Valid Until') }}{{ endDate }}
</p>
</div>
<p class="text-sm text-muted-foreground">
{{ t('License Valid Until') }}{{ endDate }}
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button variant="default" @click="updateLicense">
<Icon name="ri:upload-fill" class="mr-2 size-4" />
{{ t('Update License') }}
</Button>
<Button variant="outline" @click="contactSupport">
<Icon name="ri:customer-service-2-line" class="mr-2 size-4" />
{{ t('Contact Support') }}
</Button>
<div class="flex flex-wrap items-center gap-3">
<Button variant="default" @click="updateLicense">
<Icon name="ri:upload-fill" class="mr-2 size-4" />
{{ t('Update License') }}
</Button>
<Button variant="outline" @click="contactSupport">
<Icon name="ri:customer-service-2-line" class="mr-2 size-4" />
{{ t('Contact Support') }}
</Button>
</div>
</div>
</div>
</AppCard>
</CardContent>
</Card>
<div class="grid gap-6 lg:grid-cols-2">
<AppCard class="space-y-4">
<p class="text-base font-semibold">{{ t('License Details') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in licenseDetails" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
</div>
</dl>
</AppCard>
<Card class="shadow-none">
<CardContent class="space-y-4">
<p class="text-base font-semibold">{{ t('License Details') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in licenseDetails" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
</div>
</dl>
</CardContent>
</Card>
<AppCard class="space-y-4">
<p class="text-base font-semibold">{{ t('Customer Service') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in serviceInfo" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
</div>
</dl>
</AppCard>
<Card class="shadow-none">
<CardContent class="space-y-4">
<p class="text-base font-semibold">{{ t('Customer Service') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in serviceInfo" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<AppCard class="space-y-4">
<p class="text-base font-semibold">{{ t('Feature Permissions') }}</p>
<div class="overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-muted/50 text-left text-xs uppercase text-muted-foreground">
<tr>
<th class="px-4 py-2">{{ t('Name') }}</th>
<th class="px-4 py-2">{{ t('Description') }}</th>
<th class="px-4 py-2">{{ t('Status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in permissions" :key="item.name" class="border-t">
<td class="px-4 py-3 font-medium">{{ item.name }}</td>
<td class="px-4 py-3 text-muted-foreground">{{ item.description }}</td>
<td class="px-4 py-3">
<Badge variant="default">{{ item.status }}</Badge>
</td>
</tr>
</tbody>
</table>
</div>
</AppCard>
<AppCard class="space-y-4">
<p class="text-base font-semibold">{{ t('Technical Parameters') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in technicalParameters" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
<Card class="shadow-none">
<CardContent class="space-y-4">
<p class="text-base font-semibold">{{ t('Feature Permissions') }}</p>
<div class="overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-muted/50 text-left text-xs uppercase text-muted-foreground">
<tr>
<th class="px-4 py-2">{{ t('Name') }}</th>
<th class="px-4 py-2">{{ t('Description') }}</th>
<th class="px-4 py-2">{{ t('Status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in permissions" :key="item.name" class="border-t">
<td class="px-4 py-3 font-medium">{{ item.name }}</td>
<td class="px-4 py-3 text-muted-foreground">{{ item.description }}</td>
<td class="px-4 py-3">
<Badge variant="default">{{ item.status }}</Badge>
</td>
</tr>
</tbody>
</table>
</div>
</dl>
</AppCard>
</CardContent>
</Card>
<Card class="shadow-none">
<CardContent class="space-y-4">
<p class="text-base font-semibold">{{ t('Technical Parameters') }}</p>
<dl class="grid gap-4 sm:grid-cols-2">
<div v-for="item in technicalParameters" :key="item.label" class="space-y-1">
<dt class="text-xs font-medium uppercase text-muted-foreground">{{ item.label }}</dt>
<dd class="text-sm text-foreground">{{ item.value }}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
@@ -101,8 +109,8 @@
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppCard } from '@/components/app'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+25 -21
View File
@@ -1,21 +1,26 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Lifecycle') }}</h1>
<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">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Lifecycle Rule') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</ActionBar>
</template>
<div class="flex-1 flex flex-wrap items-center justify-end gap-2">
<Label class="text-sm font-medium text-muted-foreground">{{ t('Bucket') }}</Label>
<AppSelect v-model="bucketName" :options="bucketList" :placeholder="t('Please select bucket')" class="max-w-xs" />
<Button variant="secondary" @click="handleNew">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Lifecycle Rule') }}</span>
</Button>
<Button variant="outline" @click="() => refresh()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
</page-header>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Data')" :empty-description="t('Create lifecycle rules to automate object transitions and expiration.')" />
@@ -28,15 +33,14 @@
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppSelect, AppTag } from '@/components/app'
import type { SelectOption } from '@/components/app/AppSelect.vue'
import { Label } from '@/components/ui/label'
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 { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const message = useMessage()
@@ -111,9 +115,9 @@ const columns: ColumnDef<LifecycleRule>[] = [
accessorFn: row => row.Status || '-',
cell: ({ row }) =>
h(
AppTag,
Badge,
{
tone: row.original.Status === 'Enabled' ? 'success' : 'danger',
variant: row.original.Status === 'Enabled' ? 'secondary' : 'destructive',
},
() => row.original.Status || '-',
),
+261 -121
View File
@@ -1,144 +1,247 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Server Information') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Server Information') }}</h1>
<template #actions>
<Button variant="outline" @click="getPageData">
<Icon name="ri:refresh-line" class="mr-2 size-4" />
{{ t('Sync') }}
</Button>
<ActionBar>
<Button variant="outline" @click="getPageData">
<Icon name="ri:refresh-line" class="mr-2 size-4" />
{{ t('Sync') }}
</Button>
</ActionBar>
</template>
</page-header>
<div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="space-y-1">
<p class="text-sm text-muted-foreground">{{ t('Storage Space') }}</p>
<p class="text-2xl font-semibold">{{ systemInfo?.buckets?.count ?? 0 }}</p>
</div>
<div class="space-y-1">
<p class="text-sm text-muted-foreground">{{ t('Objects') }}</p>
<p class="text-2xl font-semibold">{{ systemInfo?.objects?.count ?? 0 }}</p>
</div>
<div class="space-y-3">
<p class="text-sm font-medium text-muted-foreground">{{ t('Usage Report') }}</p>
<div class="flex items-center justify-between gap-4">
<span class="text-3xl font-semibold">{{ niceBytes(datausageinfo.total_used_capacity) }}</span>
<div class="w-32 space-y-2">
<div class="space-y-8">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card v-for="metric in summaryMetrics" :key="metric.label" class="shadow-none">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
{{ metric.label }}
</CardTitle>
<Icon :name="metric.icon" class="size-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold text-foreground">
{{ metric.display }}
</p>
<p v-if="metric.caption" class="text-xs text-muted-foreground mt-1">
{{ metric.caption }}
</p>
</CardContent>
</Card>
</div>
<Card class="shadow-none">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle>{{ t('Usage Report') }}</CardTitle>
<span class="text-sm text-muted-foreground">
{{ t('Last Scan Activity') }}: {{ lastUpdatedLabel }}
</span>
</div>
<CardDescription>
{{ t('Monitor overall storage usage and recent scanner activity at a glance.') }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<Icon name="ri:database-2-line" class="size-6 text-primary" />
<div>
<p class="text-sm text-muted-foreground">{{ t('Used Capacity') }}</p>
<p class="text-2xl font-semibold text-foreground">
{{ niceBytes(datausageinfo.total_used_capacity) }}
</p>
</div>
</div>
<div class="w-full max-w-xs space-y-2">
<Progress :model-value="usedPercent" class="h-2" />
<p class="text-xs text-muted-foreground text-right">{{ usedPercent.toFixed(0) }}%</p>
<p class="text-xs text-muted-foreground text-right">
{{ usedPercent.toFixed(0) }}%
</p>
</div>
</div>
<div class="space-y-3">
<div v-for="item in usageStats" :key="item.label" class="flex items-start gap-3 rounded-lg border p-3">
<Icon :name="item.icon" class="size-5 text-muted-foreground" />
<div class="flex-1">
<p class="text-sm font-medium text-foreground">{{ item.label }}</p>
<p class="text-xs text-muted-foreground">{{ item.value }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="space-y-4">
<p class="text-sm font-medium text-muted-foreground">{{ t('Servers') }}</p>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">{{ t('Online') }}</p>
<p class="text-2xl font-semibold">{{ onlineServers }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">{{ t('Offline') }}</p>
<p class="text-2xl font-semibold">{{ offlineServers }}</p>
<div class="grid gap-3 sm:grid-cols-3">
<div
v-for="item in usageStats"
:key="item.label"
class="rounded-lg border bg-muted/40 p-4"
>
<p class="text-xs text-muted-foreground uppercase">
{{ item.label }}
</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ item.value }}
</p>
</div>
</div>
</div>
<div class="space-y-4">
<p class="text-sm font-medium text-muted-foreground">{{ t('Disks') }}</p>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">{{ t('Online') }}</p>
<p class="text-2xl font-semibold">{{ systemInfo?.backend?.onlineDisks ?? 0 }}</p>
</div>
<div class="rounded-lg border p-4">
<p class="text-sm text-muted-foreground">{{ t('Offline') }}</p>
<p class="text-2xl font-semibold">{{ systemInfo?.backend?.offlineDisks ?? 0 }}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<div class="mt-6 grid gap-4 lg:grid-cols-3">
<div v-for="item in backendInfo" :key="item.title" class="rounded-lg border p-4">
<div class="flex items-center gap-3 text-sm font-medium text-muted-foreground">
<Icon :name="item.icon" class="size-5" />
<span>{{ item.title }}</span>
</div>
<p class="mt-3 text-xl font-semibold text-foreground">{{ item.value ?? '-' }}</p>
</div>
</div>
<div class="mt-6 space-y-4">
<div class="flex items-center justify-between">
<p class="text-base font-semibold">
{{ t('Server List') }} ({{ serverInfo.count ?? 0 }})
</p>
</div>
<Accordion type="single" collapsible class="space-y-2">
<AccordionItem v-for="(server, index) in systemInfo?.servers || []" :key="server.endpoint" :value="String(index)">
<AccordionTrigger>
<div class="flex flex-col gap-2 text-left sm:flex-row sm:items-center sm:gap-4">
<div class="flex items-center gap-2">
<span class="inline-flex h-2 w-2 rounded-full" :class="server.state === 'online' ? 'bg-emerald-500' : 'bg-rose-500'" />
<span class="font-semibold">{{ server.endpoint }}</span>
<Card class="shadow-none">
<CardHeader class="pb-3">
<CardTitle>{{ t('Infrastructure Health') }}</CardTitle>
<CardDescription>
{{ t('Real-time status of cluster servers and backend storage devices.') }}
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-sm font-medium text-muted-foreground">{{ t('Servers') }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-md border bg-background p-3">
<p class="text-xs text-muted-foreground">{{ t('Online') }}</p>
<p class="mt-1 text-xl font-semibold text-foreground">{{ onlineServers }}</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>
{{ t('Disks') }}: {{ countOnlineDrives(server, 'ok') }} / {{ server.drives.length }}
</span>
<span>
{{ t('Network') }}: {{ countOnlineNetworks(server, 'online') }} /
{{ Object.keys(server.network).length }}
</span>
<span>
{{ t('Uptime') }}: {{ dayjs().subtract(server.uptime, 'second').toNow() }}
</span>
<div class="rounded-md border bg-background p-3">
<p class="text-xs text-muted-foreground">{{ t('Offline') }}</p>
<p class="mt-1 text-xl font-semibold text-foreground">{{ offlineServers }}</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<p class="pb-2 text-xs text-muted-foreground">{{ t('Version') }}: {{ server.version }}</p>
<ScrollArea class="w-full">
<div class="flex gap-4 pb-2">
<div v-for="drive in server.drives" :key="drive.uuid" class="min-w-[260px] rounded-lg border p-4">
<p class="text-sm font-medium text-muted-foreground">{{ drive.drive_path }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ niceBytes(drive.usedspace) }} / {{ niceBytes(drive.totalspace) }}
</p>
<Progress
:model-value="drive.totalspace ? (drive.usedspace / drive.totalspace) * 100 : 0"
class="mt-3 h-2"
</div>
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-sm font-medium text-muted-foreground">{{ t('Disks') }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-md border bg-background p-3">
<p class="text-xs text-muted-foreground">{{ t('Online') }}</p>
<p class="mt-1 text-xl font-semibold text-foreground">
{{ systemInfo?.backend?.onlineDisks ?? 0 }}
</p>
</div>
<div class="rounded-md border bg-background p-3">
<p class="text-xs text-muted-foreground">{{ t('Offline') }}</p>
<p class="mt-1 text-xl font-semibold text-foreground">
{{ systemInfo?.backend?.offlineDisks ?? 0 }}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="shadow-none">
<CardHeader>
<CardTitle>{{ t('Backend Services') }}</CardTitle>
<CardDescription>
{{ t('Key services and configuration values reported by the cluster.') }}
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="item in backendInfo"
:key="item.title"
class="border bg-muted/40 shadow-none"
>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
{{ item.title }}
</CardTitle>
<Icon :name="item.icon" class="size-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<p class="text-xl font-semibold text-foreground">
{{ item.value ?? '-' }}
</p>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<Card class="shadow-none">
<CardHeader class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{{ t('Server List') }}</CardTitle>
<CardDescription>
{{ t('Inspect individual server health, disk utilization, and network status.') }}
</CardDescription>
</div>
<span class="text-sm text-muted-foreground">
{{ t('Total') }}: {{ serverInfo.count ?? 0 }}
</span>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible class="space-y-2">
<AccordionItem
v-for="(server, index) in systemInfo?.servers || []"
:key="server.endpoint"
:value="String(index)"
>
<AccordionTrigger>
<div class="flex flex-col gap-2 text-left sm:flex-row sm:items-center sm:gap-4">
<div class="flex items-center gap-2">
<span
class="inline-flex h-2 w-2 rounded-full"
:class="server.state === 'online' ? 'bg-emerald-500' : 'bg-rose-500'"
/>
<div class="mt-3 text-xs text-muted-foreground">
<p>
{{ t('Used') }}: <span class="font-medium text-foreground">{{ niceBytes(drive.usedspace) }}</span>
</p>
<p>
{{ t('Available') }}:
<span class="font-medium text-foreground">{{ niceBytes(drive.availspace) }}</span>
</p>
</div>
<span class="font-semibold">{{ server.endpoint }}</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>
{{ t('Disks') }}: {{ countOnlineDrives(server, 'ok') }} / {{ server.drives.length }}
</span>
<span>
{{ t('Network') }}: {{ countOnlineNetworks(server, 'online') }} /
{{ Object.keys(server.network).length }}
</span>
<span>
{{ t('Uptime') }}: {{ dayjs().subtract(server.uptime, 'second').toNow() }}
</span>
</div>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</AccordionTrigger>
<AccordionContent>
<p class="pb-2 text-xs text-muted-foreground">
{{ t('Version') }}: {{ server.version }}
</p>
<ScrollArea class="w-full">
<div class="flex gap-4 pb-2">
<Card
v-for="drive in server.drives"
:key="drive.uuid"
class="min-w-[260px] shadow-none"
>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
{{ drive.drive_path }}
</CardTitle>
<CardDescription class="text-xs">
{{ niceBytes(drive.usedspace) }} / {{ niceBytes(drive.totalspace) }}
</CardDescription>
</CardHeader>
<CardContent>
<Progress
:model-value="drive.totalspace ? (drive.usedspace / drive.totalspace) * 100 : 0"
class="mb-3 h-2"
/>
<div class="space-y-1 text-xs text-muted-foreground">
<p>
{{ t('Used') }}:
<span class="font-medium text-foreground">
{{ niceBytes(drive.usedspace) }}
</span>
</p>
<p>
{{ t('Available') }}:
<span class="font-medium text-foreground">
{{ niceBytes(drive.availspace) }}
</span>
</p>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
</page>
</template>
@@ -147,6 +250,7 @@
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import Progress from '@/components/ui/progress/Progress.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -180,6 +284,8 @@ const datausageinfo = ref<any>({})
const storageinfo = ref<any>({})
const serverInfo = ref<any>({})
const numberFormatter = new Intl.NumberFormat()
const usedPercent = computed(() => {
const total = Number(datausageinfo.value.total_capacity || 0)
if (!total) return 0
@@ -187,6 +293,40 @@ const usedPercent = computed(() => {
return Math.min(100, Math.max(0, (used / total) * 100))
})
const lastUpdatedLabel = computed(() => {
const last = metricsInfo.value?.aggregated?.scanner?.current_started
const time = dayjs(last)
return time.isValid() ? time.fromNow() : '--'
})
const summaryMetrics = computed(() => {
const bucketCount = systemInfo.value?.buckets?.count ?? 0
const objectCount = systemInfo.value?.objects?.count ?? 0
const totalCapacity = datausageinfo.value?.total_capacity
const usedCapacity = datausageinfo.value?.total_used_capacity
return [
{
label: t('Storage Space'),
display: numberFormatter.format(bucketCount),
icon: 'ri:archive-line',
caption: null,
},
{
label: t('Objects'),
display: numberFormatter.format(objectCount),
icon: 'ri:stack-line',
caption: null,
},
{
label: t('Total Capacity'),
display: totalCapacity ? niceBytes(String(totalCapacity)) : '--',
icon: 'ri:hard-drive-2-line',
caption: usedCapacity ? `${t('Used')}: ${niceBytes(String(usedCapacity))}` : null,
},
]
})
const fromLsatStartTime = computed(() => {
const times = metricsInfo.value?.aggregated?.scanner?.cycle_complete_times || []
if (!times.length) return '--'
+9 -17
View File
@@ -1,21 +1,14 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('IAM Policies') }}</h1>
<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">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('New Policy') }}</span>
</Button>
</template>
<div class="flex flex-col gap-4 w-full md:flex-row md:items-center md:justify-between">
<div class="flex w-full max-w-sm items-center gap-2">
<Icon name="ri:search-2-line" class="size-4 text-muted-foreground" />
<Input v-model="searchTerm" :placeholder="t('Search')" />
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button variant="secondary" @click="handleNew">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('New Policy') }}</span>
</Button>
</div>
</div>
</page-header>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Policies')" :empty-description="t('Create a policy to manage access control templates.')" />
@@ -26,14 +19,13 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import DataTable from '@/components/data-table/data-table.vue'
import { useDataTable } from '@/components/data-table/useDataTable'
import type { ColumnDef } from '@tanstack/vue-table'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const { $api } = useNuxtApp()
+25 -21
View File
@@ -1,21 +1,26 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Bucket Replication') }}</h1>
<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">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Replication Rule') }}</span>
</Button>
<Button variant="outline" @click="() => loadReplication()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</ActionBar>
</template>
<div class="flex-1 flex flex-wrap items-center justify-end gap-2">
<Label class="text-sm font-medium text-muted-foreground">{{ t('Bucket') }}</Label>
<AppSelect v-model="bucketName" :options="bucketList" :placeholder="t('Please select bucket')" class="max-w-xs" />
<Button variant="secondary" @click="openForm">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Replication Rule') }}</span>
</Button>
<Button variant="outline" @click="() => loadReplication()">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
</page-header>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Data')" :empty-description="t('Add replication rules to sync objects across buckets.')" />
@@ -28,16 +33,15 @@
import { Button } from '@/components/ui/button'
import { Icon } from '#components'
import { AppSelect, AppTag } from '@/components/app'
import type { SelectOption } from '@/components/app/AppSelect.vue'
import { Label } from '@/components/ui/label'
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 { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const message = useMessage()
@@ -103,8 +107,8 @@ const columns: ColumnDef<ReplicationRule>[] = [
header: () => t('Status'),
cell: ({ row }) =>
h(
AppTag,
{ tone: row.original.Status === 'Enabled' ? 'success' : 'warning' },
Badge,
{ variant: row.original.Status === 'Enabled' ? 'secondary' : 'outline' },
() => (row.original.Status === 'Enabled' ? t('Enabled') : t('Disabled'))
),
},
+10 -9
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { configManager } from '~/utils/config'
@@ -93,9 +94,7 @@ const currentItems = computed(() => [
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Settings') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Settings') }}</h1>
</page-header>
<div class="flex flex-col gap-6">
@@ -114,13 +113,15 @@ const currentItems = computed(() => [
<div class="space-y-4">
<h2 class="text-lg font-semibold">{{ t('Server Configuration') }}</h2>
<form class="space-y-4" @submit.prevent="saveConfig">
<div class="space-y-2">
<Label class="text-sm font-medium">{{ t('Server Address') }}</Label>
<Input v-model="formData.serverHost" :placeholder="t('Please enter server address (e.g., http://localhost:9000)')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<Field>
<FieldLabel>{{ t('Server Address') }}</FieldLabel>
<FieldContent>
<Input v-model="formData.serverHost" :placeholder="t('Please enter server address (e.g., http://localhost:9000)')" autocomplete="off" />
</FieldContent>
<FieldDescription>
{{ t('Example: http://localhost:9000 or https://your-domain.com') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="flex flex-wrap items-center gap-2">
<Button type="submit" variant="default" :loading="loading">
+2 -4
View File
@@ -1,9 +1,7 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Site Replication') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Site Replication') }}</h1>
</page-header>
<div class="flex flex-col gap-4">
<div class="flex justify-end">
@@ -13,7 +11,7 @@
</Button>
</div>
<Card class="min-h-[400px]">
<Card class="min-h-[400px] shadow-none">
<CardContent class="flex h-full items-center justify-center">
<p class="text-sm text-muted-foreground">{{ t('No Data') }}</p>
</CardContent>
+410 -309
View File
@@ -1,9 +1,7 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Server-Side Encryption (SSE) Configuration') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Server-Side Encryption (SSE) Configuration') }}</h1>
<template #description>
<p class="text-gray-600 dark:text-gray-400">
{{ t('Configure server-side encryption for your objects using external key management services.') }}
@@ -11,22 +9,45 @@
</template>
</page-header>
<div>
<!-- KMS 状态显示 -->
<AppCard class="mb-6 space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-3">
<Badge :variant="kmsStatusVariant" class="text-sm uppercase">
{{ getKmsStatusText() }}
</Badge>
<span class="text-sm text-muted-foreground">{{ getKmsStatusDescription() }}</span>
<span v-if="sseKmsForm.kms_backend" class="text-xs text-foreground/70">
{{ t('Backend') }}: {{ sseKmsForm.backend_type }}
</span>
<div class="space-y-8">
<!-- KMS 总览 -->
<Card class="shadow-none">
<CardHeader class="space-y-2">
<div class="flex flex-wrap items-center gap-3">
<CardTitle class="text-base sm:text-lg">{{ t('KMS Status Overview') }}</CardTitle>
<Badge :variant="kmsStatusVariant" class="text-sm uppercase">
{{ getKmsStatusText() }}
</Badge>
</div>
<CardDescription>
{{ getKmsStatusDescription() }}
</CardDescription>
<CardDescription v-if="sseKmsForm.kms_backend">
{{ t('Backend') }}: {{ sseKmsForm.backend_type }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-md border bg-muted/40 p-3">
<p class="text-xs text-muted-foreground">{{ t('Backend Type') }}</p>
<p class="text-sm font-medium text-foreground">
{{ sseKmsForm.backend_type ? getKmsTypeName(sseKmsForm.backend_type) : t('Not configured') }}
</p>
</div>
<div class="rounded-md border bg-muted/40 p-3">
<p class="text-xs text-muted-foreground">{{ t('Cache Status') }}</p>
<p class="text-sm font-medium text-foreground">
{{ sseKmsForm.enable_cache ? t('Enabled') : t('Disabled') }}
</p>
</div>
<div class="rounded-md border bg-muted/40 p-3">
<p class="text-xs text-muted-foreground">{{ t('Default Key ID') }}</p>
<p class="text-sm font-medium text-foreground">
{{ sseKmsForm.default_key_id || t('Not configured') }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" :loading="refreshingStatus" @click="refreshStatus">
<Icon name="ri:refresh-line" class="mr-2 size-4" />
{{ t('Refresh') }}
@@ -39,101 +60,117 @@
<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>
</div>
</div>
</AppCard>
</CardContent>
</Card>
<!-- KMS 配置区域 -->
<div class="mb-6 space-y-6">
<div class="space-y-2">
<h2 class="text-lg font-semibold">{{ t('KMS Configuration') }}</h2>
</div>
<div v-if="!isEditing" class="space-y-4">
<!-- 当前配置显示 -->
<div v-if="sseKmsForm.backend_type" class="rounded-lg border bg-muted/30 p-4 dark:bg-muted/10">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="space-y-2">
<p class="text-sm font-semibold text-foreground">
{{ t('Current KMS Type') }}: {{ getKmsTypeName(sseKmsForm.backend_type) }}
</p>
<div class="space-y-2 text-sm text-muted-foreground">
<!-- Vault backend specific fields -->
<template v-if="sseKmsForm.backend_type === 'vault'">
<div>
{{ t('Vault Server') }}:
<span v-if="sseKmsForm.address === 'configured-but-hidden'" class="italic">
{{ t('(Configuration details are private)') }}
</span>
<span v-else>{{ sseKmsForm.address || t('Not specified') }}</span>
</div>
<div v-if="sseKmsForm.mount_path">{{ t('Transit Mount') }}: {{ sseKmsForm.mount_path }}</div>
<div v-if="sseKmsForm.kv_mount">{{ t('KV Mount') }}: {{ sseKmsForm.kv_mount }}</div>
<div v-if="sseKmsForm.key_path_prefix">
{{ t('Key Path Prefix') }}: {{ sseKmsForm.key_path_prefix }}
</div>
<div v-if="sseKmsForm.auth_type">
{{ t('Auth Method') }}: {{ sseKmsForm.auth_type === 'approle' ? 'AppRole' : 'Token' }}
</div>
</template>
<Card class="shadow-none">
<CardHeader class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<CardTitle>{{ t('KMS Configuration') }}</CardTitle>
<CardDescription>
{{ t('Manage how RustFS connects to your external key management service.') }}
</CardDescription>
</div>
<div v-if="!isEditing" class="flex gap-2">
<Button size="sm" variant="default" @click="startEditing">
{{ hasConfiguration ? t('Edit Configuration') : t('Configure KMS') }}
</Button>
</div>
</CardHeader>
<CardContent class="space-y-6">
<div v-if="!isEditing" class="space-y-4">
<div v-if="sseKmsForm.backend_type" class="rounded-lg border bg-muted/30 p-4 dark:bg-muted/10">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="space-y-2">
<p class="text-sm font-semibold text-foreground">
{{ t('Current KMS Type') }}: {{ getKmsTypeName(sseKmsForm.backend_type) }}
</p>
<div class="space-y-2 text-sm text-muted-foreground">
<!-- Vault backend specific fields -->
<template v-if="sseKmsForm.backend_type === 'vault'">
<div>
{{ t('Vault Server') }}:
<span v-if="sseKmsForm.address === 'configured-but-hidden'" class="italic">
{{ t('(Configuration details are private)') }}
</span>
<span v-else>{{ sseKmsForm.address || t('Not specified') }}</span>
</div>
<div v-if="sseKmsForm.mount_path">{{ t('Transit Mount') }}: {{ sseKmsForm.mount_path }}</div>
<div v-if="sseKmsForm.kv_mount">{{ t('KV Mount') }}: {{ sseKmsForm.kv_mount }}</div>
<div v-if="sseKmsForm.key_path_prefix">
{{ t('Key Path Prefix') }}: {{ sseKmsForm.key_path_prefix }}
</div>
<div v-if="sseKmsForm.auth_type">
{{ t('Auth Method') }}: {{ sseKmsForm.auth_type === 'approle' ? 'AppRole' : 'Token' }}
</div>
</template>
<!-- Local backend specific fields -->
<template v-if="sseKmsForm.backend_type === 'local'">
<div v-if="sseKmsForm.key_directory">{{ t('Key Directory') }}: {{ sseKmsForm.key_directory }}</div>
<div v-if="sseKmsForm.has_master_key !== undefined">
{{ t('Master Key') }}: {{ sseKmsForm.has_master_key ? t('Configured') : t('Not configured') }}
<!-- Local backend specific fields -->
<template v-if="sseKmsForm.backend_type === 'local'">
<div v-if="sseKmsForm.key_directory">{{ t('Key Directory') }}: {{ sseKmsForm.key_directory }}</div>
<div v-if="sseKmsForm.has_master_key !== undefined">
{{ t('Master Key') }}: {{ sseKmsForm.has_master_key ? t('Configured') : t('Not configured') }}
</div>
</template>
<div v-if="sseKmsForm.timeout_seconds">{{ t('Timeout') }}: {{ sseKmsForm.timeout_seconds }}s</div>
<div v-if="sseKmsForm.retry_attempts">{{ t('Retry Attempts') }}: {{ sseKmsForm.retry_attempts }}</div>
<div v-if="sseKmsForm.enable_cache !== undefined">
{{ t('Cache Enabled') }}: {{ sseKmsForm.enable_cache ? t('Yes') : t('No') }}
</div>
<div v-if="sseKmsForm.cache_ttl_seconds">
{{ t('Cache TTL') }}: {{ sseKmsForm.cache_ttl_seconds }}s
</div>
<div v-if="sseKmsForm.default_key_id && sseKmsForm.default_key_id !== 'configured-but-hidden'">
{{ t('Default Key ID') }}: {{ sseKmsForm.default_key_id }}
</div>
<div v-else-if="sseKmsForm.default_key_id === 'configured-but-hidden'">
{{ t('Default Key ID') }}: <span class="italic">{{ t('(Configured)') }}</span>
</div>
<div v-if="sseKmsForm.kms_status">
{{ t('Status') }}:
<Badge :variant="kmsStatusVariant" class="text-xs uppercase">
{{ getKmsStatusText() }}
</Badge>
</div>
</template>
<div v-if="sseKmsForm.timeout_seconds">{{ t('Timeout') }}: {{ sseKmsForm.timeout_seconds }}s</div>
<div v-if="sseKmsForm.retry_attempts">{{ t('Retry Attempts') }}: {{ sseKmsForm.retry_attempts }}</div>
<div v-if="sseKmsForm.enable_cache !== undefined">
{{ t('Cache Enabled') }}: {{ sseKmsForm.enable_cache ? t('Yes') : t('No') }}
</div>
<div v-if="sseKmsForm.cache_ttl_seconds">
{{ t('Cache TTL') }}: {{ sseKmsForm.cache_ttl_seconds }}s
</div>
<div v-if="sseKmsForm.default_key_id && sseKmsForm.default_key_id !== 'configured-but-hidden'">
{{ t('Default Key ID') }}: {{ sseKmsForm.default_key_id }}
</div>
<div v-else-if="sseKmsForm.default_key_id === 'configured-but-hidden'">
{{ t('Default Key ID') }}: <span class="italic">{{ t('(Configured)') }}</span>
</div>
<div v-if="sseKmsForm.kms_status">
{{ t('Status') }}:
<AppTag :tone="kmsStatusTone" class="text-xs uppercase">
{{ getKmsStatusText() }}
</AppTag>
</div>
</div>
</div>
<Button size="sm" variant="outline" @click="startEditing">
{{ t('Edit Configuration') }}
</div>
<!-- 未配置状态 -->
<div v-else class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-8 text-muted-foreground">
<Icon name="ri:key-2-line" class="mb-2 text-4xl" />
<div class="text-sm">{{ t('No KMS configuration found') }}</div>
<Button size="sm" variant="default" @click="startEditing">
{{ t('Configure KMS') }}
</Button>
</div>
</div>
<!-- 未配置状态 -->
<div v-else class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-8 text-muted-foreground">
<Icon name="ri:key-2-line" class="mb-2 text-4xl" />
<div class="text-sm">{{ t('No KMS configuration found') }}</div>
<Button size="sm" variant="default" @click="startEditing">
{{ t('Configure KMS') }}
</Button>
</div>
</div>
<!-- 编辑模式表单 -->
<form v-else class="space-y-6" @submit.prevent="saveConfiguration">
<!-- 编辑模式表单 -->
<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">
@@ -141,107 +178,147 @@
</p>
</div>
<div class="space-y-2">
<Label for="kms-address">{{ t('Vault Server Address') }}</Label>
<Input id="kms-address" v-model="sseKmsForm.address" :placeholder="t('e.g., https://vault.example.com:8200')" autocomplete="off" />
</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>
<div class="space-y-3">
<Label>{{ t('Authentication Method') }}</Label>
<AppRadioGroup v-model="sseKmsForm.auth_type" :options="authTypeOptions" class="grid gap-3 md:grid-cols-2" item-class="h-full" />
</div>
<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>
</label>
</RadioGroup>
</FieldContent>
</Field>
<div v-if="sseKmsForm.auth_type === 'token'" class="space-y-2">
<Label for="kms-token">{{ t('Vault Token') }}</Label>
<Input id="kms-token" v-model="sseKmsForm.vault_token" type="password" autocomplete="off" :placeholder="t('Enter your Vault authentication token')" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div v-if="sseKmsForm.auth_type === 'approle'" class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="kms-role-id">{{ t('Role ID') }}</Label>
<Input id="kms-role-id" v-model="sseKmsForm.vault_app_role_id" :placeholder="t('Enter AppRole Role ID')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
<div class="space-y-2">
<Label for="kms-secret-id">{{ t('Secret ID') }}</Label>
<Input id="kms-secret-id" v-model="sseKmsForm.vault_app_role_secret_id" type="password" autocomplete="off" :placeholder="t('Enter AppRole Secret ID')" />
<p class="text-xs text-muted-foreground">
</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') }}
</p>
</div>
</FieldDescription>
</Field>
</div>
<div class="space-y-2">
<Label for="kms-mount-path">{{ t('Transit Mount Path') }}</Label>
<Input id="kms-mount-path" v-model="sseKmsForm.mount_path" :placeholder="t('transit')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="space-y-2">
<Label for="kms-kv-mount">{{ t('KV Mount Path') }}</Label>
<Input id="kms-kv-mount" v-model="sseKmsForm.kv_mount" :placeholder="t('secret')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="space-y-2">
<Label for="kms-key-prefix">{{ t('Key Path Prefix') }}</Label>
<Input id="kms-key-prefix" v-model="sseKmsForm.key_path_prefix" :placeholder="t('rustfs/kms/keys')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="grid gap-6 md:grid-cols-2">
<div class="space-y-2">
<Label for="kms-timeout">{{ t('Timeout (seconds)') }}</Label>
<Input id="kms-timeout" v-model="sseKmsForm.timeout_seconds" type="number" placeholder="30" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
<div class="space-y-2">
<Label for="kms-retry">{{ t('Retry Attempts') }}</Label>
<Input id="kms-retry" v-model="sseKmsForm.retry_attempts" type="number" placeholder="3" />
<p class="text-xs text-muted-foreground">
</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') }}
</p>
</div>
</FieldDescription>
</Field>
</div>
<div class="space-y-2">
<Label for="kms-default-key">{{ t('Default Key ID') }}</Label>
<Input id="kms-default-key" v-model="sseKmsForm.default_key_id" :placeholder="t('rustfs-master')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="space-y-2">
<Label>{{ t('Enable Cache') }}</Label>
<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>
<p class="text-xs text-muted-foreground">
{{ t('Enable caching for better performance, default: true') }}
</p>
</div>
</FieldContent>
<FieldDescription>
{{ t('Enable caching for better performance, default: true') }}
</FieldDescription>
</Field>
<div v-if="sseKmsForm.enable_cache" class="space-y-2">
<Label for="kms-cache-ttl">{{ t('Cache TTL (seconds)') }}</Label>
<Input id="kms-cache-ttl" v-model="sseKmsForm.cache_ttl_seconds" type="number" placeholder="600" />
<p class="text-xs text-muted-foreground">
<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') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" @click="cancelEditing">
@@ -252,11 +329,30 @@
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
<!-- KMS 密钥列表 -->
<AppCard :title="t('KMS Keys Management')" class="mb-6" content-class="space-y-6">
<div class="space-y-6">
<Card class="shadow-none">
<CardHeader class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<CardTitle>{{ t('KMS Keys Management') }}</CardTitle>
<CardDescription>
{{ t('Create, rotate, and inspect the keys managed by your KMS backend.') }}
</CardDescription>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" :loading="refreshingKeys" :disabled="sseKmsForm.kms_status !== 'Running'" @click="refreshKeyList">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
<Button size="sm" variant="default" :disabled="!canAddKeys" @click="showCreateKeyModal = true">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Create Key') }}</span>
</Button>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- 密钥类型说明 -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 class="font-medium text-blue-900 dark:text-blue-100 mb-3">{{ t('Understanding Key Types') }}</h4>
@@ -288,26 +384,14 @@
<!-- 主密钥管理区域 -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-4 h-4 bg-purple-500 rounded-full"></div>
<h3 class="text-lg font-medium">{{ t('Master Keys (CMK)') }}</h3>
<AppTag tone="info">
{{
kmsKeys.filter(key => !key.key_type || key.key_type === 'master' || key.key_type === 'CMK').length
}}
</AppTag>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" :loading="refreshingKeys" :disabled="sseKmsForm.kms_status !== 'Running'" @click="refreshKeyList">
<Icon name="ri:refresh-line" class="size-4" />
{{ t('Refresh') }}
</Button>
<Button size="sm" variant="default" :disabled="!canAddKeys" @click="showCreateKeyModal = true">
<Icon name="ri:add-line" class="size-4" />
{{ t('Create Master Key') }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="w-4 h-4 rounded-full bg-purple-500"></div>
<h3 class="text-lg font-medium">{{ t('Master Keys (CMK)') }}</h3>
<Badge variant="secondary">
{{
kmsKeys.filter(key => !key.key_type || key.key_type === 'master' || key.key_type === 'CMK').length
}}
</Badge>
</div>
<!-- 主密钥列表 -->
@@ -331,9 +415,9 @@
<div v-if="key.algorithm">{{ t('Algorithm') }}: {{ key.algorithm }}</div>
<div>
{{ t('Status') }}:
<AppTag :tone="getKeyStatusType(key)">
<Badge :variant="getKeyStatusVariant(key)">
{{ getKeyStatusText(key) }}
</AppTag>
</Badge>
</div>
<div v-if="key.createdAt">{{ t('Created') }}: {{ formatDate(new Date(key.createdAt)) }}</div>
<div v-else-if="key.creation_date">
@@ -363,9 +447,9 @@
<div class="flex items-center space-x-3">
<div class="w-4 h-4 bg-green-500 rounded-full"></div>
<h3 class="text-lg font-medium">{{ t('Data Keys (DEK)') }}</h3>
<AppTag tone="success">
<Badge variant="secondary">
{{kmsKeys.filter(key => key.key_type === 'data' || key.key_type === 'DEK').length}}
</AppTag>
</Badge>
</div>
<!-- 数据密钥说明 -->
@@ -398,9 +482,9 @@
<div v-if="key.algorithm">{{ t('Algorithm') }}: {{ key.algorithm }}</div>
<div>
{{ t('Status') }}:
<AppTag :tone="getKeyStatusType(key)">
<Badge :variant="getKeyStatusVariant(key)">
{{ getKeyStatusText(key) }}
</AppTag>
</Badge>
</div>
<div v-if="key.createdAt">{{ t('Created') }}: {{ formatDate(new Date(key.createdAt)) }}</div>
<div v-else-if="key.creation_date">
@@ -419,20 +503,23 @@
</div>
<!-- 空状态 -->
<div v-if="kmsKeys.length === 0" class="text-center py-8 text-gray-500">
<Icon name="ri:key-2-line" class="text-4xl mx-auto mb-2" />
<div>{{ t('No KMS keys found') }}</div>
<div class="text-sm">{{ t('Create your first KMS key to get started') }}</div>
<Button class="mt-2" :disabled="!canAddKeys" @click="showCreateKeyModal = true">
<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>
</div>
</div>
</AppCard>
</EmptyState>
</CardContent>
</Card>
<!-- Bucket 加密配置管理 -->
<AppCard class="mt-6" content-class="space-y-4">
<template #header>
<Card class="mt-6 shadow-none">
<CardHeader>
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold">{{ t('Bucket Encryption Management') }}</h3>
<Button size="sm" :loading="bucketListLoading" @click="refreshBucketList">
@@ -440,31 +527,37 @@
{{ t('Refresh') }}
</Button>
</div>
</template>
<div class="space-y-4">
</CardHeader>
<CardContent class="space-y-4">
<!-- 搜索和排序 -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative flex-1">
<Icon name="ri:search-line" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="bucketSearchQuery" :placeholder="t('Search buckets...')" class="pl-10" />
<button v-if="bucketSearchQuery" type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
@click="bucketSearchQuery = ''">
<Icon name="ri:close-circle-line" class="size-4" />
</button>
</div>
<AppSelect 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="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex justify-between items-center">
<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">
<Icon name="ri:folder-3-line" class="text-lg text-blue-500" />
<div>
<h4 class="font-medium text-lg">{{ bucket.name }}</h4>
<h4 class="text-lg font-medium">{{ bucket.name }}</h4>
<div class="text-sm text-gray-500">
{{ t('Created') }}: {{ formatDateTime(bucket.creationDate) }}
</div>
@@ -475,8 +568,8 @@
<div class="flex items-center space-x-4">
<!-- 加密状态显示 -->
<div class="text-center">
<div class="text-sm text-gray-500 mb-1">{{ t('Encryption Status') }}</div>
<AppTag :tone="bucket.encryptionStatus === 'Enabled' ? 'success' : 'default'">
<div class="mb-1 text-sm text-gray-500">{{ t('Encryption Status') }}</div>
<Badge :variant="bucket.encryptionStatus === 'Enabled' ? 'secondary' : 'outline'">
{{
bucket.encryptionStatus === 'Enabled'
? bucket.encryptionType === 'SSE-KMS'
@@ -484,7 +577,7 @@
: 'SSE-S3'
: t('Not configured')
}}
</AppTag>
</Badge>
</div>
<!-- 操作按钮 -->
@@ -493,8 +586,13 @@
<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>
@@ -503,7 +601,10 @@
</div>
<!-- 加密详细信息 -->
<div v-if="bucket.encryptionStatus === 'Enabled'" class="mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<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 }}
@@ -515,53 +616,59 @@
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!bucketListLoading && buckets.length === 0" class="text-center py-8 text-gray-500">
<Icon name="ri:folder-2-line" class="text-4xl mx-auto mb-2" />
<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="text-center py-8 text-gray-500">
<Icon name="ri:search-line" class="text-4xl mx-auto mb-2" />
<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>
</div>
<!-- 加载状态 -->
<div v-if="bucketListLoading" class="text-center py-8">
<AppSpinner size="lg" class="mx-auto" />
<div class="text-gray-500 mt-2">{{ t('Loading buckets...') }}</div>
<div v-if="bucketListLoading" class="py-8 text-center">
<Spinner class="mx-auto size-6 text-muted-foreground" />
<div class="mt-2 text-gray-500">{{ t('Loading buckets...') }}</div>
</div>
</div>
</AppCard>
</CardContent>
</Card>
<!-- 配置 Bucket 加密模态框 -->
<AppModal v-model="showBucketEncryptModal" :title="selectedBucket
<Modal v-model="showBucketEncryptModal" :title="selectedBucket
? t('Configure Encryption for {bucket}', { bucket: selectedBucket.name })
: t('Configure Bucket Encryption')
" size="lg" :close-on-backdrop="false">
<div class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Encryption Type') }}</Label>
<AppSelect v-model="bucketEncryptForm.encryptionType" :options="bucketEncryptionOptions" :placeholder="t('Select encryption type')" />
<p class="text-xs text-muted-foreground">
<Field>
<FieldLabel>{{ t('Encryption Type') }}</FieldLabel>
<FieldContent>
<Selector v-model="bucketEncryptForm.encryptionType" :options="bucketEncryptionOptions" :placeholder="t('Select encryption type')" />
</FieldContent>
<FieldDescription>
{{ t('Choose the encryption method for this bucket') }}
</p>
</div>
</FieldDescription>
</Field>
<div v-if="bucketEncryptForm.encryptionType === 'SSE-KMS'" class="space-y-2">
<Label>{{ t('KMS Key') }}</Label>
<AppSelect v-model="bucketEncryptForm.kmsKeyId" :options="kmsKeyOptions" :placeholder="t('Select KMS key')" :disabled="kmsKeysLoading" />
<p class="text-xs text-muted-foreground">
<Field v-if="bucketEncryptForm.encryptionType === 'SSE-KMS'">
<FieldLabel>{{ t('KMS Key') }}</FieldLabel>
<FieldContent class="space-y-1.5">
<Selector v-model="bucketEncryptForm.kmsKeyId" :options="kmsKeyOptions" :placeholder="t('Select KMS key')" :disabled="kmsKeysLoading" />
<span v-if="kmsKeysLoading" class="text-xs text-muted-foreground">
{{ t('Loading keys...') }}
</span>
</FieldContent>
<FieldDescription>
{{ t('Select the KMS key to use for encryption') }}
</p>
<div v-if="kmsKeysLoading" class="text-xs text-muted-foreground">
{{ t('Loading keys...') }}
</div>
</div>
</FieldDescription>
</Field>
</div>
<template #footer>
@@ -574,10 +681,10 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<!-- 移除加密确认模态框 -->
<AppModal v-model="showRemoveEncryptModal" :title="t('Confirm Remove Encryption')" size="lg" :close-on-backdrop="false">
<Modal v-model="showRemoveEncryptModal" :title="t('Confirm Remove Encryption')" size="lg" :close-on-backdrop="false">
<div class="flex flex-col items-center gap-2 py-4 text-center text-muted-foreground">
<Icon name="ri:alert-line" class="text-4xl text-orange-500" />
<div class="text-lg font-medium text-foreground">
@@ -600,26 +707,30 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<!-- 创建/编辑密钥模态框 -->
<AppModal v-model="showCreateKeyModal" :title="t('Create New Key')" size="lg" :close-on-backdrop="false">
<Modal v-model="showCreateKeyModal" :title="t('Create New Key')" size="lg" :close-on-backdrop="false">
<div class="space-y-4">
<div class="space-y-2">
<Label>{{ t('Key Name') }}</Label>
<Input v-model="keyForm.keyName" :placeholder="t('e.g., app-default')" autocomplete="off" />
<p class="text-xs text-muted-foreground">
<Field>
<FieldLabel>{{ t('Key Name') }}</FieldLabel>
<FieldContent>
<Input v-model="keyForm.keyName" :placeholder="t('e.g., app-default')" autocomplete="off" />
</FieldContent>
<FieldDescription>
{{ t('Main key ID (Transit key name). Use business-related readable ID.') }}
</p>
</div>
</FieldDescription>
</Field>
<div class="space-y-2">
<Label>{{ t('Algorithm') }}</Label>
<AppSelect v-model="keyForm.algorithm" :options="algorithmOptions" :placeholder="t('Select encryption algorithm')" />
<p class="text-xs text-muted-foreground">
<Field>
<FieldLabel>{{ t('Algorithm') }}</FieldLabel>
<FieldContent>
<Selector v-model="keyForm.algorithm" :options="algorithmOptions" :placeholder="t('Select encryption algorithm')" />
</FieldContent>
<FieldDescription>
{{ t('Encryption algorithm for the key.') }}
</p>
</div>
</FieldDescription>
</Field>
</div>
<template #footer>
@@ -632,10 +743,10 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<!-- 删除确认模态框 -->
<AppModal v-model="showDeleteModal" :title="t('Confirm Delete')" size="lg" :close-on-backdrop="false">
<Modal v-model="showDeleteModal" :title="t('Confirm Delete')" size="lg" :close-on-backdrop="false">
<div class="flex flex-col items-center gap-2 py-4 text-center text-muted-foreground">
<Icon name="ri:alert-line" class="text-4xl text-red-500" />
<div class="text-lg font-medium text-foreground">
@@ -657,10 +768,10 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<!-- 强制删除确认模态框 -->
<AppModal v-model="showForceDeleteModal" :title="t('Confirm Force Delete')" size="lg" :close-on-backdrop="false">
<Modal v-model="showForceDeleteModal" :title="t('Confirm Force Delete')" size="lg" :close-on-backdrop="false">
<div class="flex flex-col items-center gap-2 py-4 text-center text-muted-foreground">
<Icon name="ri:alert-line" class="text-4xl text-red-500" />
<div class="text-lg font-medium text-foreground">
@@ -685,10 +796,10 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
<!-- 详细状态查看模态框 -->
<AppModal v-model="showDetailedStatusModal" :title="t('Detailed KMS Status')" size="lg">
<Modal v-model="showDetailedStatusModal" :title="t('Detailed KMS Status')" size="lg">
<div v-if="detailedStatusData" class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1">
@@ -701,9 +812,9 @@
</div>
<div class="space-y-1">
<p class="text-xs font-semibold uppercase text-muted-foreground">{{ t('Cache Enabled') }}</p>
<AppTag :tone="detailedStatusData.cache_enabled ? 'success' : 'default'">
<Badge :variant="detailedStatusData.cache_enabled ? 'secondary' : 'outline'">
{{ detailedStatusData.cache_enabled ? t('Enabled') : t('Disabled') }}
</AppTag>
</Badge>
</div>
<div class="space-y-1">
<p class="text-xs font-semibold uppercase text-muted-foreground">{{ t('Default Key ID') }}</p>
@@ -758,7 +869,7 @@
</Button>
</div>
</template>
</AppModal>
</Modal>
</div>
</page>
</template>
@@ -767,10 +878,13 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AppCard, AppRadioGroup, AppSelect, AppSpinner, AppTag } from '@/components/app'
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 { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
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'
@@ -807,37 +921,22 @@ const startingKMS = ref(false)
const stoppingKMS = ref(false)
const detailedStatusData = ref<any>(null)
const kmsStatusVariant = computed(() => {
const type = getKmsStatusType()
switch (type) {
const toneToBadgeVariant = (tone: 'success' | 'warning' | 'danger' | 'info' | 'default') => {
switch (tone) {
case 'success':
return 'default'
case 'warning':
return 'outline'
return 'secondary'
case 'danger':
return 'destructive'
case 'info':
return 'secondary'
default:
return 'secondary'
}
})
const kmsStatusTone = computed(() => {
const type = getKmsStatusType()
switch (type) {
case 'success':
return 'success'
case 'warning':
return 'warning'
case 'danger':
return 'danger'
return 'outline'
case 'info':
return 'info'
return 'secondary'
default:
return 'default'
return 'outline'
}
})
}
const kmsStatusVariant = computed(() => toneToBadgeVariant(getKmsStatusType()))
//
const showCreateKeyModal = ref(false)
@@ -1200,7 +1299,7 @@ const mapKeyState = (key: any) => {
}
//
const getKeyStatusType = (key: any) => {
const getKeyStatusTone = (key: any) => {
const status = mapKeyState(key)
switch (status) {
case 'Active':
@@ -1216,6 +1315,8 @@ const getKeyStatusType = (key: any) => {
}
}
const getKeyStatusVariant = (key: any) => toneToBadgeVariant(getKeyStatusTone(key))
//
const getKeyStatusText = (key: any) => {
const status = mapKeyState(key)
+5 -7
View File
@@ -1,10 +1,8 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Tiers') }}</h1>
</template>
<div class="flex flex-wrap items-center justify-end gap-2 w-full">
<h1 class="text-2xl font-bold">{{ t('Tiers') }}</h1>
<template #actions>
<Button variant="secondary" @click="openNewForm">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Tier') }}</span>
@@ -13,7 +11,7 @@
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</Button>
</div>
</template>
</page-header>
<DataTable :table="table" :is-loading="loading" :empty-title="t('No Tiers')" :empty-description="t('Add tiers to configure remote storage destinations.')" />
@@ -27,11 +25,11 @@
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 { ColumnDef } from '@tanstack/vue-table'
import { h, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataTable } from '~/components/data-table'
import DataTable from '~/components/data-table/data-table.vue'
const { t } = useI18n()
const message = useMessage()
+1 -3
View File
@@ -1,9 +1,7 @@
<template>
<page>
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Users') }}</h1>
</template>
<h1 class="text-2xl font-bold">{{ t('Users') }}</h1>
</page-header>
<div>
<Tabs v-model="activeTab" class="flex flex-col gap-4">