refactor: migrate lifecycle management to shadcn components

This commit is contained in:
overtrue
2025-10-26 09:39:56 +08:00
parent 32ce9c6afa
commit d7fd4fd56b
2 changed files with 582 additions and 577 deletions
+407 -389
View File
@@ -1,425 +1,443 @@
<template>
<n-modal
v-model:show="visible"
:mask-closable="false"
preset="card"
:title="t('Add Lifecycle Rule') + ` (${t('Bucket')}: ${bucketName})`"
class="max-w-screen-md"
:segmented="{
content: true,
action: true,
}"
<AppModal
v-model="visible"
:title="`${t('Add Lifecycle Rule')} (${t('Bucket')}: ${bucketName})`"
size="lg"
:close-on-backdrop="false"
>
<n-card>
<n-form class="my-4" ref="formRef" :model="formData" :rules="rules">
<n-tabs default-value="expire" justify-content="space-evenly" type="line" @update:value="handleUpdateValue">
<n-tab-pane name="expire" :tab="t('Expiration')">
<n-form-item :label="t('Object Version')" path="versionType" v-if="versioningStatus">
<n-select v-model:value="formData.versionType" :options="versionOptions" />
</n-form-item>
<n-form-item :label="t('Time Cycle')" path="days">
<div class="w-full flex items-center justify-between">
<n-input-number
class="flex-auto"
v-model:value="formData.days"
:min="1"
<div class="space-y-6">
<Tabs v-model="activeTab" class="flex flex-col gap-4">
<TabsList class="w-full justify-start overflow-x-auto">
<TabsTrigger value="expire">{{ t('Expiration') }}</TabsTrigger>
<TabsTrigger value="transition">{{ t('Transition') }}</TabsTrigger>
</TabsList>
<TabsContent value="expire" class="mt-0 space-y-6">
<AppCard padded 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>
<div class="grid gap-2">
<Label>{{ t('Time Cycle') }}</Label>
<div class="flex items-center gap-3">
<AppInput
v-model="formData.days"
type="number"
min="1"
class="w-32"
:placeholder="t('Days')"
style="width: 100px"
/>
<span class="ms-4">{{ t('Days After') }}</span>
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</n-form-item>
<!-- 生命周期 -->
<n-card>
<n-collapse>
<n-collapse-item :title="t('More Configurations')" name="advanced">
<n-form-item :label="t('Prefix')">
<n-input v-model:value="formData.prefix" :placeholder="t('Please enter prefix')" />
</n-form-item>
<n-form-item :label="t('Tags')">
<n-dynamic-input
v-model:value="formData.tags"
preset="pair"
:key-placeholder="t('Tag Name')"
:value-placeholder="t('Tag Value')"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-card>
<!-- 高级设置 -->
<n-card class="my-4" v-if="formData.versionType == 'current'">
<n-collapse>
<n-collapse-item :title="t('Advanced Settings')" name="advanced">
<n-form-item :label="t('Delete Marker Handling')">
<n-space>
<n-switch v-model:value="formData.expiredDeleteMark" :round="false" />
<span class="ml-4 text-gray-500">{{
t('If no versions remain, delete references to this object')
}}</span>
</n-space>
</n-form-item>
</div>
</AppCard>
<!-- <n-form-item label="版本清理">
<n-space>
<n-switch v-model:value="formData.deleteAllExpired" :round="false" />
<span class="ml-4 text-gray-500">删除所有过期版本</span>
</n-space>
</n-form-item> -->
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane>
<n-tab-pane name="transition" :tab="t('Transition')">
<n-form-item :label="t('Object Version')" path="versionType" v-if="versioningStatus">
<n-select v-model:value="formData.versionType" :options="versionOptions" />
</n-form-item>
<n-form-item :label="t('Time Cycle')" path="days">
<div class="w-full flex items-center justify-between">
<n-input-number
class="flex-auto"
v-model:value="formData.days"
:min="1"
<AppCard padded 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>
<AppInput v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
<AppButton variant="outline" size="sm" @click="addTag">
<Icon name="ri:add-line" class="size-4" />
{{ t('Add Tag') }}
</AppButton>
</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 border-border/60 p-3 md:grid-cols-2 md:items-center md:gap-4"
>
<AppInput v-model="tag.key" :placeholder="t('Tag Name')" />
<div class="flex items-center gap-2">
<AppInput v-model="tag.value" :placeholder="t('Tag Value')" class="flex-1" />
<AppButton
variant="ghost"
size="sm"
class="text-destructive"
:disabled="formData.tags.length === 1"
@click="removeTag(index)"
>
<Icon name="ri:delete-bin-line" class="size-4" />
</AppButton>
</div>
</div>
</div>
</div>
</div>
</details>
</AppCard>
<AppCard v-if="formData.versionType === 'current'" padded>
<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>
<AppSwitch v-model="formData.expiredDeleteMark" />
</div>
</details>
</AppCard>
</TabsContent>
<TabsContent value="transition" class="mt-0 space-y-6">
<AppCard padded 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>
<div class="grid gap-2">
<Label>{{ t('Time Cycle') }}</Label>
<div class="flex items-center gap-3">
<AppInput
v-model="formData.days"
type="number"
min="1"
class="w-32"
:placeholder="t('Days')"
style="width: 100px"
/>
<span class="ms-4">{{ t('Days After') }}</span>
<span class="text-sm text-muted-foreground">{{ t('Days After') }}</span>
</div>
</n-form-item>
<n-form-item :label="t('Storage Type')" path="storageType">
<n-select v-model:value="formData.storageType" :options="tiers" />
</n-form-item>
</div>
<!-- 生命周期 -->
<n-card>
<n-collapse>
<n-collapse-item :title="t('More Configurations')" name="advanced">
<n-form-item :label="t('Prefix')">
<n-input v-model:value="formData.prefix" :placeholder="t('Please enter prefix')" />
</n-form-item>
<n-form-item :label="t('Tags')">
<n-dynamic-input
v-model:value="formData.tags"
preset="pair"
:key-placeholder="t('Tag Name')"
:value-placeholder="t('Tag Value')"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-card>
</n-tab-pane>
</n-tabs>
</n-form>
<div class="grid gap-2">
<Label>{{ t('Storage Type') }}</Label>
<AppSelect
v-model="formData.storageType"
:options="tiers"
:placeholder="t('Please select storage type')"
/>
</div>
</AppCard>
<n-space justify="center">
<n-button @click="handleCancel">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="handleSave">{{ t('Save') }}</n-button>
</n-space>
</n-card>
</n-modal>
<AppCard padded 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>
<AppInput v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">{{ t('Tags') }}</Label>
<AppButton variant="outline" size="sm" @click="addTag">
<Icon name="ri:add-line" class="size-4" />
{{ t('Add Tag') }}
</AppButton>
</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 border-border/60 p-3 md:grid-cols-2 md:items-center md:gap-4"
>
<AppInput v-model="tag.key" :placeholder="t('Tag Name')" />
<div class="flex items-center gap-2">
<AppInput v-model="tag.value" :placeholder="t('Tag Value')" class="flex-1" />
<AppButton
variant="ghost"
size="sm"
class="text-destructive"
:disabled="formData.tags.length === 1"
@click="removeTag(index)"
>
<Icon name="ri:delete-bin-line" class="size-4" />
</AppButton>
</div>
</div>
</div>
</div>
</div>
</details>
</AppCard>
</TabsContent>
</Tabs>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="outline" @click="handleCancel">{{ t('Cancel') }}</AppButton>
<AppButton variant="primary" :loading="submitting" @click="handleSave">{{ t('Save') }}</AppButton>
</div>
</template>
</AppModal>
</template>
<script setup lang="ts">
import {
NButton,
NCollapse,
NCollapseItem,
NDynamicInput,
NForm,
NFormItem,
NInput,
NInputNumber,
NSelect,
} from 'naive-ui';
// 使用 Web Crypto API 或 Node.js crypto 模块
const randomUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// 兜底方案
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { putBucketLifecycleConfiguration, getBucketVersioning, getBucketLifecycleConfiguration } = useBucket({});
const { listTiers } = useTiers();
const { t } = useI18n();
const message = useMessage();
// 定义 tiers 数据结构的接口
interface TierItem {
label: string;
value: string;
}
import { Icon } from '#components'
import { AppButton, AppCard, AppInput, AppModal, AppSelect, AppSwitch } from '@/components/app'
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'
const emit = defineEmits(['search']);
const formRef = ref();
const formData = ref({
ruleName: '',
type: null,
const { t } = useI18n()
const message = useMessage()
const { putBucketLifecycleConfiguration, getBucketVersioning, getBucketLifecycleConfiguration } = useBucket({})
const { listTiers } = useTiers()
const props = defineProps<{
bucketName: string
}>()
const emit = defineEmits<{
(e: 'search'): void
}>()
const visible = ref(false)
const activeTab = ref<'expire' | 'transition'>('expire')
const submitting = ref(false)
const versioningStatus = ref(false)
const versionOptions = computed(() => [
{ label: t('Current Version'), value: 'current' },
{ label: t('Non-current Version'), value: 'non-current' },
])
const formData = reactive({
versionType: 'current',
days: null,
action: 'expire',
days: 1,
storageType: '',
prefix: '',
expiredDeleteMark: false,
deleteAllExpired: false,
storageType: '',
tags: [
{
key: '',
value: '',
},
],
});
] as Array<{ key: string; value: string }>,
})
const versionOptions = ref([
{ label: t('Current Version'), value: 'current' },
{ label: t('Non-current Version'), value: 'non-current' },
]);
watch(
() => activeTab.value,
value => {
formState.action = value === 'expire' ? 'expire' : 'transition'
},
{ immediate: true }
)
// 表单验证规则
const rules = {
days: {
required: true,
validator: (rule: any, value: any) => {
if (value === null || value === undefined || value < 1) {
return new Error(t('Please enter valid days'));
}
return true;
},
trigger: ['blur', 'input', 'change'],
},
storageType: {
validator: (rule: any, value: any) => {
// 只在 transition 模式下验证 storageType
if (formData.value.action === 'transition' && (!value || value === '')) {
return new Error(t('Please select storage type'));
}
return true;
},
trigger: ['blur', 'change'],
},
};
const formState = reactive({
action: 'expire' as 'expire' | 'transition',
})
const props = defineProps({
bucketName: {
type: String,
required: true,
},
// 已经存在的生命周期
lifecycleList: {
type: Array,
default: () => [],
},
});
const tiers = ref<Array<{ label: string; value: string }>>([])
const loadTiers = async () => {
const res = await listTiers()
if (res) {
tiers.value = res.map((item: any) => ({
label: item[item.type].name,
value: item[item.type].name,
}))
}
if (!formData.storageType && tiers.value.length > 0) {
formData.storageType = tiers.value[0].value
}
}
const loadVersioningStatus = async () => {
if (!props.bucketName) return
try {
const resp = await getBucketVersioning(props.bucketName)
versioningStatus.value = resp.Status === 'Enabled'
} catch (error) {
versioningStatus.value = false
}
}
watch(
() => props.bucketName,
newVal => {
// 获取是都开启版本管理
getVersioningStatus();
() => {
loadVersioningStatus()
}
);
)
const visible = ref(false);
const open = () => {
visible.value = true;
};
const addTag = () => {
formData.tags.push({ key: '', value: '' })
}
const removeTag = (index: number) => {
if (formData.tags.length === 1) return
formData.tags.splice(index, 1)
}
const resetForm = () => {
activeTab.value = 'expire'
formState.action = 'expire'
formData.versionType = 'current'
formData.days = 1
formData.storageType = tiers.value[0]?.value ?? ''
formData.prefix = ''
formData.expiredDeleteMark = false
formData.tags = [{ key: '', value: '' }]
}
const validate = () => {
if (!formData.days || Number(formData.days) < 1) {
message.error(t('Please enter valid days'))
return false
}
if (
formState.action === 'transition' &&
(!formData.storageType || formData.storageType === '')
) {
message.error(t('Please select storage type'))
return false
}
if (!formData.tags.some(tag => tag.key && tag.value)) {
// allow but no error
}
return true
}
const randomUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
const buildFilter = () => {
const validTags = formData.tags.filter(tag => tag.key && tag.value)
if (!formData.prefix && validTags.length === 0) {
return undefined
}
const filter: any = {}
if (validTags.length === 1 && !formData.prefix) {
filter.Tag = {
Key: validTags[0].key,
Value: validTags[0].value,
}
} else if (validTags.length > 0) {
filter.And = {
Tags: validTags.map(tag => ({
Key: tag.key,
Value: tag.value,
})),
}
if (formData.prefix) {
filter.And.Prefix = formData.prefix
}
} else if (formData.prefix) {
filter.Prefix = formData.prefix
}
if (formData.prefix && validTags.length === 1 && !filter.And) {
filter.Prefix = formData.prefix
filter.Tag = {
Key: validTags[0].key,
Value: validTags[0].value,
}
} else if (formData.prefix && validTags.length === 0) {
filter.Prefix = formData.prefix
}
return Object.keys(filter).length ? filter : undefined
}
const handleSave = async () => {
if (!validate()) return
submitting.value = true
try {
const currentConfig = await getBucketLifecycleConfiguration(props.bucketName)
const newRule: any = {
ID: randomUUID(),
Status: 'Enabled',
Filter: buildFilter(),
}
const daysValue = Number(formData.days)
if (formState.action === 'expire') {
if (formData.versionType === 'non-current') {
newRule.NoncurrentVersionExpiration = {
NoncurrentDays: daysValue,
}
if (formData.expiredDeleteMark) {
newRule.ExpiredObjectDeleteMarker = true
}
} else {
newRule.Expiration = {
Days: daysValue,
}
if (formData.expiredDeleteMark) {
newRule.Expiration.ExpiredObjectDeleteMarker = true
}
}
} else {
if (formData.versionType === 'non-current') {
newRule.NoncurrentVersionTransitions = [
{
NoncurrentDays: daysValue,
StorageClass: formData.storageType,
},
]
} else {
newRule.Transitions = [
{
Days: daysValue,
StorageClass: formData.storageType,
},
]
}
}
const existingRules = currentConfig.Rules || []
const payload = {
Rules: [...existingRules, newRule],
}
await putBucketLifecycleConfiguration(props.bucketName, payload)
message.success(t('Create Success'))
emit('search')
handleCancel()
} catch (error: any) {
message.error(error?.message || t('Failed to create rule'))
} finally {
submitting.value = false
}
}
const handleCancel = () => {
visible.value = false
resetForm()
}
defineExpose({
open,
});
const handleUpdateValue = (value: string) => {
if (value === 'expire') {
formData.value.action = 'expire';
} else {
formData.value.action = 'transition';
}
};
const handleSave = () => {
formRef.value?.validate((errors: any) => {
if (!errors) {
// 额外的参数验证(与表单验证保持一致)
if (formData.value.days === null || formData.value.days === undefined || formData.value.days < 1) {
message.error(t('Please enter valid days'));
return;
}
if (
formData.value.action === 'transition' &&
(!formData.value.storageType || formData.value.storageType === '')
) {
message.error(t('Please select storage type'));
return;
}
// 先获取当前的生命周期配置
getBucketLifecycleConfiguration(props.bucketName)
.then(currentConfig => {
// 创建新的规则
const newRule: any = {
ID: randomUUID(),
Status: 'Enabled',
};
// 设置 Filter 或 Prefix(不能同时使用)
console.log('formData.value.prefix:', formData.value.prefix);
console.log('formData.value.tags:', formData.value.tags);
console.log('formData.value.expiredDeleteMark:', formData.value.expiredDeleteMark);
// 检查是否有有效的标签
const validTags = formData.value.tags.filter((item: any) => item.key && item.value);
const hasValidTags = validTags.length > 0;
if (formData.value.prefix || hasValidTags) {
newRule.Filter = {};
if (hasValidTags) {
if (validTags.length === 1) {
newRule.Filter.Tag = {
Key: validTags[0].key,
Value: validTags[0].value,
};
if (formData.value.prefix) {
newRule.Filter.Prefix = formData.value.prefix;
}
} else {
newRule.Filter.And = {
Tags: validTags.map((item: any) => ({
Key: item.key,
Value: item.value,
})),
};
if (formData.value.prefix) {
newRule.Filter.And.Prefix = formData.value.prefix;
}
}
} else {
// 只有前缀时,直接添加到 Filter
newRule.Filter.Prefix = formData.value.prefix;
}
}
// 根据操作类型添加相应的配置
if (formData.value.action === 'expire') {
if (formData.value.versionType === 'non-current') {
newRule.NoncurrentVersionExpiration = {
NoncurrentDays: formData.value.days,
};
// 如果启用了删除标记处理
if (formData.value.expiredDeleteMark) {
newRule.ExpiredObjectDeleteMarker = true;
}
} else {
newRule.Expiration = {
Days: formData.value.days,
};
// 如果启用了删除标记处理(当前版本也支持)
if (formData.value.expiredDeleteMark) {
newRule.Expiration.ExpiredObjectDeleteMarker = true;
}
}
} else {
// transition 操作
if (formData.value.versionType === 'non-current') {
newRule.NoncurrentVersionTransitions = [
{
NoncurrentDays: formData.value.days,
StorageClass: formData.value.storageType,
},
];
} else {
newRule.Transitions = [
{
Days: formData.value.days,
StorageClass: formData.value.storageType,
},
];
}
}
// 合并现有规则和新规则
const existingRules = currentConfig.Rules || [];
const updatedRules = [...existingRules, newRule];
// 调用保存接口
const params = {
Rules: updatedRules,
};
return putBucketLifecycleConfiguration(props.bucketName, params);
})
.then(res => {
visible.value = false;
emit('search');
message.success(t('Create Success'));
formData.value = {
ruleName: '',
type: null,
versionType: 'current',
days: null,
action: 'expire',
expiredDeleteMark: false,
deleteAllExpired: false,
storageType: '',
prefix: '',
tags: [
{
key: '',
value: '',
},
],
};
})
.catch(e => {
message.error(e.message);
});
}
});
};
// 分层列表
const tiers = ref<TierItem[]>([]);
const getTiers = async () => {
const res = await listTiers();
if (res) {
tiers.value = res.map((item: any) => {
return {
label: item[item.type].name,
value: item[item.type].name,
};
});
}
if (tiers.value.length) {
formData.value.storageType = tiers.value[0].value;
}
};
getTiers();
// 获取版本控制状态
const versioningStatus: any = ref(false);
const getVersioningStatus = async () => {
try {
const resp = await getBucketVersioning(props.bucketName);
versioningStatus.value = resp.Status == 'Enabled';
} catch (error) {
console.error('获取版本控制状态失败:', error);
}
};
open: async () => {
await Promise.all([loadTiers(), loadVersioningStatus()])
resetForm()
visible.value = true
},
})
onMounted(() => {
getVersioningStatus();
});
const handleCancel = () => {
// 取消逻辑
visible.value = false;
};
loadTiers()
loadVersioningStatus()
})
</script>