refactor: migrate replication ui to shadcn components

This commit is contained in:
overtrue
2025-10-26 09:48:52 +08:00
parent 02da4ab381
commit 1b10ef75fc
2 changed files with 475 additions and 535 deletions
+305 -347
View File
@@ -1,173 +1,163 @@
<template>
<n-modal
v-model:show="visible"
:mask-closable="false"
preset="card"
:title="t('Add Bucket Replication Rule', { bucket: bucketName })"
class="max-w-screen-md"
:segmented="{
content: true,
action: true,
}"
<AppModal
v-model="visible"
:title="t('Add Replication Rule') + ` (${t('Bucket')}: ${bucketName})`"
size="xl"
:close-on-backdrop="false"
>
<n-card>
<n-form label-placement="left" :label-width="100" ref="formRef" :model="formData">
<n-form-item :label="t('Priority')" path="level">
<n-input v-model:value="formData.level" :placeholder="t('Please enter priority')" />
</n-form-item>
<n-form-item :label="t('Target Address')" path="endpoint">
<n-input
v-model:value="formData.endpoint"
:placeholder="t('Please enter target address') + ':127.0.0.1:9000'"
/>
</n-form-item>
<n-form-item :label="t('Use TLS')" path="tls">
<n-switch v-model:value="formData.tls" :round="false" />
</n-form-item>
<n-form-item :label="t('Access Key')" path="accesskey">
<n-input v-model:value="formData.accesskey" :placeholder="t('Please enter Access Key')" />
</n-form-item>
<n-form-item :label="t('Secret Key')" path="secrretkey">
<n-input v-model:value="formData.secrretkey" :placeholder="t('Please enter Secret Key')" />
</n-form-item>
<n-form-item :label="t('Target Bucket')" path="bucket">
<n-input v-model:value="formData.bucket" :placeholder="t('Please enter target bucket')" />
</n-form-item>
<n-form-item :label="t('Region')" path="region">
<n-input v-model:value="formData.region" :placeholder="t('Please enter region')" />
</n-form-item>
<n-form-item :label="t('Replication Mode')" path="modeType">
<n-select
v-model:value="formData.modeType"
:placeholder="t('Please select replication mode')"
filterable
:options="modes"
/>
</n-form-item>
<div class="space-y-6">
<AppCard padded class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="grid gap-2">
<Label>{{ t('Priority') }}</Label>
<AppInput 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>
<AppInput v-model="formData.endpoint" :placeholder="t('Please enter endpoint')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Bucket') }}</Label>
<AppInput v-model="formData.bucket" :placeholder="t('Please enter bucket')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Access Key') }}</Label>
<AppInput v-model="formData.accesskey" :placeholder="t('Please enter Access Key')" autocomplete="off" />
</div>
<div class="grid gap-2">
<Label>{{ t('Secret Key') }}</Label>
<AppInput v-model="formData.secrretkey" type="password" autocomplete="off" :placeholder="t('Please enter Secret Key')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Region') }}</Label>
<AppInput v-model="formData.region" :placeholder="t('Please enter region')" />
</div>
<div class="grid gap-2">
<Label>{{ t('Storage Class') }}</Label>
<AppInput v-model="formData.storageType" :placeholder="t('Please enter storage class')" />
</div>
</div>
<n-form-item v-if="formData.modeType === 'async'" :label="t('Bandwidth')" path="bandwidth">
<n-input-group>
<n-input v-model:value="formData.bandwidth" :placeholder="t('Please enter bandwidth')" />
<n-select
v-model:value="formData.unit"
:placeholder="t('Please select unit')"
filterable
:options="units"
/>
</n-input-group>
</n-form-item>
<n-form-item :label="t('Health Check Duration')" path="timecheck">
<n-input v-model:value="formData.timecheck" :placeholder="t('Please enter health check duration')">
<template #suffix>s</template>
</n-input>
</n-form-item>
<n-form-item :label="t('Storage Type')" path="storageType">
<n-input v-model:value="formData.storageType" :placeholder="t('Please enter storage type')" />
</n-form-item>
<div class="grid gap-2">
<Label>{{ t('Prefix') }}</Label>
<AppInput v-model="formData.prefix" :placeholder="t('Please enter prefix')" />
</div>
<!-- 对象搜索 -->
<n-card :title="t('Object Search')">
<n-form-item :label="t('Prefix')">
<n-input v-model="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-card>
<!-- 复制选项 -->
<n-card class="my-4">
<n-collapse>
<n-collapse-item :title="t('Replication Options')" name="advanced">
<div style="text-align: right">
<n-form-item :label="t('Existing Objects')">
<n-space align="center" justify="end">
<n-switch v-model:value="formData.existingObject" :round="false" />
<span class="ml-4 text-gray-500">{{ t('Replicate existing objects') }}</span>
</n-space>
</n-form-item>
<!-- <n-form-item :label="t('Metadata Sync')">
<n-space align="center" justify="end">
<n-switch v-model:value="formData.deleteAllExpired" :round="false" />
<span class="ml-4 text-gray-500">{{ t("Sync metadata") }}</span>
</n-space>
</n-form-item> -->
<n-form-item :label="t('Delete Marker')">
<n-space align="center" justify="end">
<n-switch v-model:value="formData.expiredDeleteMark" :round="false" />
<span class="ml-4 text-gray-500">{{ t('Replicate soft delete') }}</span>
</n-space>
</n-form-item>
<!-- <n-form-item :label="t('Delete')">
<n-space align="center" justify="end">
<n-switch v-model:value="formData.deleteforever" :round="false" />
<span class="ml-4 text-gray-500">{{ t("Replicate version delete") }}</span>
</n-space>
</n-form-item> -->
<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>
</n-collapse-item>
</n-collapse>
</n-card>
</div>
</div>
</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-form>
</n-card>
</n-modal>
<AppCard padded class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('Use TLS') }}</p>
<p class="text-xs text-muted-foreground">{{ t('Enable secure transport when connecting to endpoint.') }}</p>
</div>
<AppSwitch v-model="formData.tls" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('Replicate Existing Objects') }}</p>
<p class="text-xs text-muted-foreground">{{ t('Include objects that already exist in the source bucket.') }}</p>
</div>
<AppSwitch v-model="formData.existingObject" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('Replicate Delete Markers') }}</p>
<p class="text-xs text-muted-foreground">{{ t('Sync delete markers to destination bucket.') }}</p>
</div>
<AppSwitch v-model="formData.expiredDeleteMark" />
</div>
<div class="space-y-3" v-if="formData.modeType === 'async'">
<div class="grid gap-2">
<Label>{{ t('Health Check Interval (seconds)') }}</Label>
<AppInput
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">
<AppInput v-model="formData.bandwidth" type="number" min="0" class="w-32" />
<AppSelect v-model="formData.unit" :options="unitOptions" class="w-28" />
</div>
</div>
</div>
</AppCard>
</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>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { NForm, NFormItem, NInput, NDynamicInput, NCollapse, NCollapseItem, NSwitch, NButton } from 'naive-ui';
import { useBucket } from '@/composables/useBucket';
import { getBytes } from '@/utils/functions';
<script setup lang="ts">
import { Icon } from '#components'
import { AppButton, AppCard, AppInput, AppModal, AppSelect, AppSwitch } from '@/components/app'
import { Label } from '@/components/ui/label'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getBytes } from '@/utils/functions'
const { t } = useI18n();
const { t } = useI18n()
const message = useMessage()
const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket({})
const modes = [
{
label: t('Sync'),
value: 'sync',
},
{
label: t('Async'),
value: 'async',
},
];
const props = defineProps<{
bucketName: string
}>()
const units = [
{
label: 'Mi',
value: 'Mi',
},
{
label: 'Gi',
value: 'Gi',
},
{
label: 'Ti',
value: 'Ti',
},
{
label: 'Pi',
value: 'Pi',
},
{
label: 'Ei',
value: 'Ei',
},
];
const emit = defineEmits<{
(e: 'success'): void
}>()
const formRef = ref(null);
const formData = ref({
const visible = ref(false)
const submitting = ref(false)
const formData = reactive({
level: '1',
endpoint: '',
tls: false,
@@ -181,210 +171,178 @@ const formData = ref({
bandwidth: 100,
storageType: 'STANDARD',
prefix: '',
tags: [
{
key: '',
value: '',
},
],
tags: [{ key: '', value: '' }],
existingObject: true,
expiredDeleteMark: true,
// deleteAllExpired: false,
delete: false,
deleteforever: false,
});
})
const props = defineProps({
bucketName: {
type: String,
required: true,
},
});
const modeOptions = computed(() => [
{ label: t('Asynchronous'), value: 'async' },
{ label: t('Synchronous'), value: 'sync' },
])
const visible = ref(false);
const open = () => {
visible.value = true;
};
const unitOptions = computed(() => [
{ label: 'KiB/s', value: 'Ki' },
{ label: 'MiB/s', value: 'Mi' },
{ label: 'GiB/s', value: 'Gi' },
])
defineExpose({
open,
});
const addTag = () => {
formData.tags.push({ key: '', value: '' })
}
const removeTag = (index: number) => {
if (formData.tags.length === 1) return
formData.tags.splice(index, 1)
}
const resetForm = () => {
formData.level = '1'
formData.endpoint = ''
formData.tls = false
formData.accesskey = ''
formData.secrretkey = ''
formData.bucket = ''
formData.region = 'us-east-1'
formData.modeType = 'async'
formData.timecheck = '60'
formData.unit = 'Gi'
formData.bandwidth = 100
formData.storageType = 'STANDARD'
formData.prefix = ''
formData.tags = [{ key: '', value: '' }]
formData.existingObject = true
formData.expiredDeleteMark = true
}
const validate = () => {
if (!formData.endpoint) {
message.error(t('Please enter endpoint'))
return false
}
if (!formData.bucket) {
message.error(t('Please enter bucket'))
return false
}
if (!formData.accesskey || !formData.secrretkey) {
message.error(t('Please provide credentials'))
return false
}
if (formData.modeType === 'async' && Number(formData.timecheck) < 1) {
message.error(t('Please enter valid health check interval'))
return false
}
return true
}
const emit = defineEmits(['success']);
// 创建远程复制目标
const { setRemoteReplicationTarget, putBucketReplication, getBucketReplication } = useBucket({});
const handleSave = async () => {
formRef.value?.validate(async errors => {
if (!errors) {
try {
let config = {
sourcebucket: props.bucketName,
endpoint: formData.value.endpoint,
credentials: {
accessKey: formData.value.accesskey,
secretKey: formData.value.secrretkey,
expiration: '0001-01-01T00:00:00Z',
},
targetbucket: formData.value.bucket,
secure: formData.value.tls,
region: formData.value.region,
path: 'auto',
api: 's3v4',
type: 'replication',
replicationSync: formData.value.modeType === 'sync' ? true : false,
healthCheckDuration: formData.value.timecheck - 0,
disableProxy: false,
resetBeforeDate: '0001-01-01T00:00:00Z',
totalDowntime: 0,
lastOnline: '0001-01-01T00:00:00Z',
isOnline: false,
latency: {
curr: 0,
avg: 0,
max: 0,
},
edge: false,
edgeSyncBeforeExpiry: false,
};
if (!validate()) return
submitting.value = true
try {
const config: any = {
sourcebucket: props.bucketName,
endpoint: formData.endpoint,
credentials: {
accessKey: formData.accesskey,
secretKey: formData.secrretkey,
expiration: '0001-01-01T00:00:00Z',
},
targetbucket: formData.bucket,
secure: formData.tls,
region: formData.region,
path: 'auto',
api: 's3v4',
type: 'replication',
replicationSync: formData.modeType === 'sync',
healthCheckDuration: Number(formData.timecheck) || 60,
disableProxy: false,
resetBeforeDate: '0001-01-01T00:00:00Z',
totalDowntime: 0,
lastOnline: '0001-01-01T00:00:00Z',
isOnline: false,
latency: { curr: 0, avg: 0, max: 0 },
edge: false,
edgeSyncBeforeExpiry: false,
}
// 添加带宽
if (formData.value.modeType === 'async') {
// 根据单位转化为字节
config.bandwidth = Number(getBytes(formData.value.bandwidth, formData.value.unit, true)) || 0;
}
let targetRESP = await setRemoteReplicationTarget(props.bucketName, config);
if (formData.modeType === 'async') {
config.bandwidth = Number(getBytes(String(formData.bandwidth), formData.unit, true)) || 0
}
if (!targetRESP) {
return;
}
const targetResponse = await setRemoteReplicationTarget(props.bucketName, config)
if (!targetResponse) {
throw new Error('Failed to create replication target')
}
// 获取已有的 replication 配置
let oldConfig = null;
try {
oldConfig = await getBucketReplication(props.bucketName);
} catch (e) {
console.log(e);
// 没有配置时会报错,忽略即可
}
let oldConfig: any = null
try {
oldConfig = await getBucketReplication(props.bucketName)
} catch (error) {
oldConfig = null
}
// 构造新规则
const newRule = {
ID: `replication-rule-${Date.now()}`,
Status: 'Enabled',
Priority: parseInt(formData.value.level) || 1,
Filter: (() => {
const filter = {};
if (formData.value.prefix) {
filter.Prefix = formData.value.prefix;
}
const validTags = formData.value.tags.filter(tag => tag.key && tag.value);
if (validTags.length > 0) {
if (validTags.length === 1) {
filter.Tag = {
Key: validTags[0].key,
Value: validTags[0].value,
};
} else {
filter.And = {
Prefix: formData.value.prefix || '',
Tags: validTags.map(tag => ({
Key: tag.key,
Value: tag.value,
})),
};
}
}
return filter;
})(),
SourceSelectionCriteria: {
SseKmsEncryptedObjects: {
Status: 'Enabled',
},
},
ExistingObjectReplication: {
Status: formData.value.existingObject ? 'Enabled' : 'Disabled',
},
DeleteMarkerReplication: {
Status: formData.value.expiredDeleteMark ? 'Enabled' : 'Disabled',
},
Destination: {
Bucket: targetRESP,
StorageClass: formData.value.storageType ? formData.value.storageType.toUpperCase() : 'STANDARD',
// ReplicateDelete: formData.value.deleteforever ? "Enabled" : "Disabled",
},
};
const newRule: any = {
ID: `replication-rule-${Date.now()}`,
Status: 'Enabled',
Priority: Number.parseInt(formData.level) || 1,
Filter: {},
SourceSelectionCriteria: {
SseKmsEncryptedObjects: { Status: 'Enabled' },
},
ExistingObjectReplication: {
Status: formData.existingObject ? 'Enabled' : 'Disabled',
},
DeleteMarkerReplication: {
Status: formData.expiredDeleteMark ? 'Enabled' : 'Disabled',
},
Destination: {
Bucket: targetResponse,
StorageClass: formData.storageType || 'STANDARD',
},
}
// 合并规则
let rules = [];
if (
oldConfig &&
oldConfig.ReplicationConfiguration &&
Array.isArray(oldConfig.ReplicationConfiguration.Rules)
) {
// 如果有 oldConfig.ReplicationConfiguration.Rules
targetRESP = null;
// 查找是否有存在的Destination Bucket,如果存在则替换整个规则
const findOne = oldConfig.ReplicationConfiguration.Rules.find(rule => rule.Destination.Bucket === targetRESP);
if (findOne) {
// 如果有则替换
rules = [...oldConfig.ReplicationConfiguration.Rules].splice(
oldConfig.ReplicationConfiguration.Rules.indexOf(findOne),
1,
newRule
);
} else {
rules = [...oldConfig.ReplicationConfiguration.Rules, newRule];
}
} else {
rules = [newRule];
}
if (formData.prefix) {
newRule.Filter.Prefix = formData.prefix
}
const params = {
Role: targetRESP,
Rules: rules,
};
// 创建复制规则
await putBucketReplication(props.bucketName, params);
emit('success');
handleCancel();
} catch (e) {
console.log('🚀 ~ handleSave ~ e:', e);
window.$message?.error?.(t('Save failed'));
const validTags = formData.tags.filter(tag => tag.key && tag.value)
if (validTags.length === 1) {
newRule.Filter.Tag = { Key: validTags[0].key, Value: validTags[0].value }
} else if (validTags.length > 1) {
newRule.Filter.And = {
Prefix: formData.prefix || '',
Tags: validTags.map(tag => ({ Key: tag.key, Value: tag.value })),
}
}
});
};
const existingRules = oldConfig?.ReplicationConfiguration?.Rules ?? []
const payload = {
Role: targetResponse,
Rules: [...existingRules.filter((rule: any) => rule.Destination?.Bucket !== targetResponse), newRule],
}
await putBucketReplication(props.bucketName, payload)
message.success(t('Create Success'))
emit('success')
handleCancel()
} catch (error: any) {
console.error(error)
message.error(error?.message || t('Save failed'))
} finally {
submitting.value = false
}
}
const handleCancel = () => {
// 取消逻辑
visible.value = false;
formData = ref({
level: '1',
endpoint: '',
tls: false,
accesskey: '',
secrretkey: '',
bucket: '',
region: 'us-east-1',
modeType: 'async',
timecheck: '60',
unit: 'Gi',
bandwidth: 100,
storageType: 'STANDARD',
prefix: '',
tags: [
{
key: '',
value: '',
},
],
existingObject: true,
expiredDeleteMark: true,
// deleteAllExpired: false,
delete: false,
deleteforever: false,
});
};
visible.value = false
submitting.value = false
resetForm()
}
defineExpose({
open: () => {
resetForm()
visible.value = true
},
})
</script>
+170 -188
View File
@@ -1,244 +1,226 @@
<template>
<div>
<div class="flex flex-col gap-6">
<page-header>
<template #title>
<h1 class="text-2xl font-bold">{{ t('Bucket Replication') }}</h1>
</template>
</page-header>
<page-content class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div style="width: 300px">
<n-form-item :label="t('Bucket')" path="" class="flex-auto" label-placement="left">
<n-select
filterable
v-model:value="bucketName"
:placeholder="t('Please select bucket')"
:options="bucketList"
/>
</n-form-item>
</div>
<div class="flex items-center gap-4">
<n-button @click="() => openForm()">
<Icon name="ri:add-line" class="mr-2" />
<page-content class="flex flex-col gap-4">
<div
class="flex flex-col gap-4 rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm md:flex-row md:items-end md:justify-between"
>
<div class="w-full max-w-sm">
<Label class="mb-2 block text-sm font-medium text-muted-foreground">{{ t('Bucket') }}</Label>
<AppSelect
v-model="bucketName"
:options="bucketList"
:placeholder="t('Please select bucket')"
class="w-full"
/>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<AppButton variant="secondary" @click="openForm">
<Icon name="ri:add-line" class="size-4" />
<span>{{ t('Add Replication Rule') }}</span>
</n-button>
<n-button @click="loadReplication">
<Icon name="ri:refresh-line" class="mr-2" />
</AppButton>
<AppButton variant="outline" @click="loadReplication">
<Icon name="ri:refresh-line" class="size-4" />
<span>{{ t('Refresh') }}</span>
</n-button>
</AppButton>
</div>
</div>
<n-data-table
class="border dark:border-neutral-700 rounded overflow-hidden"
:columns="columns"
:data="pageData"
:pagination="false"
:bordered="false"
v-if="pageData.length > 0"
/>
<n-card class="flex flex-center" style="height: 400px" v-else>
<n-empty :description="t('No Data')"></n-empty>
</n-card>
<replication-new-form :bucketName="bucketName" ref="addFromRef" @success="onAddSuccess"></replication-new-form>
<AppCard padded class="border border-border/60">
<AppDataTable
:table="table"
:is-loading="loading"
:empty-title="t('No Data')"
:empty-description="t('Add replication rules to sync objects across buckets.')"
/>
</AppCard>
<replication-new-form ref="addFormRef" :bucketName="bucketName" @success="loadReplication" />
</page-content>
</div>
</template>
<script lang="ts" setup>
import { Icon } from '#components';
import { NButton, NSpace, type DataTableColumns } from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { useBucket } from '@/composables/useBucket';
import { h, ref, computed, watch } from 'vue';
import { useMessage } from 'naive-ui';
import { Icon } from '#components'
import type { ColumnDef } from '@tanstack/vue-table'
import { AppButton, AppCard, AppSelect, AppTag } from '@/components/app'
import { AppDataTable, useDataTable } from '@/components/app/data-table'
import { Label } from '@/components/ui/label'
import { computed, h, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBucket } from '@/composables/useBucket'
const { t } = useI18n()
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n();
const {
listBuckets,
getBucketReplication,
putBucketReplication,
deleteBucketReplication,
deleteRemoteReplicationTarget,
} = useBucket({});
const formVisible = ref(false);
const searchTerm = ref('');
const message = useMessage();
} = useBucket({})
interface RowData {
Name: string;
CreationDate: string;
interface ReplicationRule {
ID?: string
Status?: string
Priority?: number
Filter?: {
Prefix?: string
}
Destination?: {
Bucket?: string
StorageClass?: string
}
}
const columns: DataTableColumns<ReplicationRule> = [
{
title: t('Rule ID'),
key: 'ID',
align: 'center',
},
{
title: t('Status'),
key: 'Status',
align: 'center',
render: row => (row.Status === 'Enabled' ? t('Enabled') : t('Disabled')),
},
{
title: t('Priority'),
key: 'Priority',
align: 'center',
},
{
title: t('Prefix'),
key: 'Filter',
align: 'center',
render: row => row.Filter?.Prefix || '-',
},
{
title: t('Destination Bucket'),
key: 'Destination',
align: 'center',
render: row => {
// MinIO/标准S3结构:arn:aws:s3:::bucketname
const bucketArn = row.Destination?.Bucket || '';
return bucketArn.replace(/^arn:aws:s3:::/, '');
},
},
{
title: t('Storage Class'),
key: 'Destination',
align: 'center',
render: row => row.Destination?.StorageClass || '-',
},
{
title: t('Actions'),
key: 'actions',
align: 'center',
width: 100,
render: row => {
return h(
NSpace,
{ justify: 'center' },
{
default: () => [
h(
NButton,
{
size: 'small',
secondary: true,
onClick: e => handleRowDelete(row, e),
},
{
default: () => '',
icon: () => h(Icon, { name: 'ri:delete-bin-7-line' }),
}
),
],
}
);
},
},
];
// 获取桶列表
const { data } = await useAsyncData(
'buckets',
const { data: bucketData } = await useAsyncData(
'replication-buckets',
async () => {
const response = await listBuckets();
const response = await listBuckets()
return (
response.Buckets?.sort((a: any, b: any) => {
return a.Name.localeCompare(b.Name);
}) || []
);
response.Buckets?.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) ?? []
)
},
{ default: () => [] }
);
)
const bucketList = computed(() => {
return data.value.map(bucket => ({
const bucketList = computed(() =>
bucketData.value.map(bucket => ({
label: bucket.Name,
value: bucket.Name,
}));
});
}))
)
const bucketName = ref<string>(bucketList.value.length > 0 ? (bucketList.value[0]?.value ?? '') : '');
const bucketName = ref<string>(bucketList.value[0]?.value ?? '')
const rules = ref<ReplicationRule[]>([])
const loading = ref(false)
// 复制规则类型
interface ReplicationRule {
[key: string]: any;
}
const pageData = ref<ReplicationRule[]>([]);
const columns: ColumnDef<ReplicationRule>[] = [
{
accessorKey: 'ID',
header: () => t('Rule ID'),
cell: ({ row }) => h('span', row.original.ID || '-'),
},
{
accessorKey: 'Status',
header: () => t('Status'),
cell: ({ row }) =>
h(
AppTag,
{ tone: row.original.Status === 'Enabled' ? 'success' : 'warning' },
() => (row.original.Status === 'Enabled' ? t('Enabled') : t('Disabled'))
),
},
{
accessorKey: 'Priority',
header: () => t('Priority'),
cell: ({ row }) => h('span', String(row.original.Priority ?? '-')),
},
{
accessorKey: 'Filter',
header: () => t('Prefix'),
cell: ({ row }) => h('span', row.original.Filter?.Prefix || '-'),
},
{
id: 'destination-bucket',
header: () => t('Destination Bucket'),
cell: ({ row }) => {
const bucketArn = row.original.Destination?.Bucket || ''
return h('span', bucketArn.replace(/^arn:aws:s3:::/, '') || '-')
},
},
{
id: 'destination-storage',
header: () => t('Storage Class'),
cell: ({ row }) => h('span', row.original.Destination?.StorageClass || '-'),
},
{
id: 'actions',
header: () => t('Actions'),
enableSorting: false,
cell: ({ row }) =>
h('div', { class: 'flex justify-center gap-2' }, [
h(
AppButton,
{
variant: 'outline',
size: 'sm',
onClick: () => confirmDelete(row.original),
},
() => [h(Icon, { name: 'ri:delete-bin-7-line', class: 'size-4' }), h('span', t('Delete'))]
),
]),
},
]
const { table } = useDataTable<ReplicationRule>({
data: rules,
columns,
getRowId: row => row.ID ?? JSON.stringify(row),
})
// 加载复制规则
const loadReplication = async () => {
if (!bucketName.value) {
pageData.value = [];
return;
rules.value = []
return
}
loading.value = true
try {
const res = await getBucketReplication(bucketName.value);
if (!res) {
pageData.value = [];
return;
}
// 兼容无规则时返回空对象
pageData.value = res?.ReplicationConfiguration?.Rules || [];
} catch (e) {
pageData.value = [];
const res = await getBucketReplication(bucketName.value)
rules.value = res?.ReplicationConfiguration?.Rules ?? []
} catch (error) {
rules.value = []
} finally {
loading.value = false
}
};
}
// 监听 bucketName 变化自动加载
watch(
bucketName,
() => bucketName.value,
() => {
loadReplication();
loadReplication()
},
{ immediate: true }
);
)
const confirmDelete = (rule: ReplicationRule) => {
dialog.error({
title: t('Warning'),
content: t('Are you sure you want to delete this replication rule?'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: () => handleRowDelete(rule),
})
}
const handleRowDelete = async (rule: ReplicationRule) => {
const remaining = rules.value.filter(item => item !== rule)
// 删除规则
const handleRowDelete = async (row: any, e: Event) => {
e.stopPropagation();
try {
// 1. 获取当前所有规则
const res: any = await getBucketReplication(bucketName.value);
let rules = res?.ReplicationConfiguration?.Rules || [];
const arn: string = res?.ReplicationConfiguration?.Role || ''; // 例如 arn:aws:s3:::bucketname
console.log(arn);
// 2. 过滤掉要删除的规则
const newRules = rules.filter((r: any) => r.ID !== row.ID);
if (newRules.length === 0) {
// 如果删除后没有规则,直接删除整个配置
await deleteBucketReplication(bucketName.value);
if (remaining.length === 0) {
await deleteBucketReplication(bucketName.value)
await deleteRemoteReplicationTarget(bucketName.value, rule.Destination?.Bucket ?? '')
} else {
// 否则 put 新规则集
await putBucketReplication(bucketName.value, {
Role: res?.ReplicationConfiguration?.Role,
Rules: newRules,
});
// 4. 删除远程目标
if (arn) {
deleteRemoteReplicationTarget(bucketName.value, arn);
}
Rules: remaining,
})
}
message.success(t('Delete success'));
loadReplication();
} catch (err) {
message.error(t('Delete failed'));
message.success(t('Delete Success'))
loadReplication()
} catch (error: any) {
message.error(error?.message || t('Delete Failed'))
}
};
}
const addFromRef = ref();
const addFormRef = ref<{ open: () => void }>()
const openForm = () => {
addFromRef.value.open();
};
// 新增成功后刷新
const onAddSuccess = () => {
loadReplication();
};
addFormRef.value?.open()
}
</script>