feat: 添加对象保留功能及相关界面优化

This commit is contained in:
马登山
2025-06-05 15:28:19 +08:00
parent 0d18c2b040
commit ced0726ed1
7 changed files with 252 additions and 81 deletions
+2 -2
View File
@@ -22,11 +22,11 @@
</div>
<div class="flex items-center justify-between gap-4">
<n-space class="w-24">{{ t("Version") }}:</n-space>
<n-switch v-model:value="version" @change="handleVersionChange" />
<n-switch v-model:value="version" @update:value="handleVersionChange" />
</div>
<div class="flex items-center justify-between gap-4">
<n-space class="w-24">{{ t("Object Lock") }}:</n-space>
<n-switch v-model:value="objectLock" @change="handleObjectLock" />
<n-switch v-model:value="objectLock" @update:value="handleObjectLock" />
</div>
<!-- Retention 配置区块,仅对象锁开启时显示 -->
+100 -7
View File
@@ -17,14 +17,10 @@
<span>{{ t("Tags") }}</span>
</n-button>
<!-- <n-button v-if="lockStatus" @click="() => true == true">
<n-button v-if="lockStatus" @click="() => (showRetentionView = true)">
<Icon name="ri:save-2-line" class="mr-2" />
<span>{{ t("Retention") }}</span>
</n-button>
<n-button v-if="lockStatus" @click="() => true == true">
<Icon name="ri:auction-line" class="mr-2" />
<span>{{ t("Legal Hold") }}</span>
</n-button> -->
<n-button id="copyTag" ref="copyRef" @click="copySignUrl">
<Icon name="ri:file-copy-line" class="mr-2" />
@@ -64,7 +60,14 @@
</n-descriptions-item>
<!-- 保留 -->
<n-descriptions-item :label="t('Retention') + t('Policy')">compliance</n-descriptions-item>
<n-descriptions-item :label="t('Retention') + t('Policy')">
<n-space>
<span>{{ t("Retention Type") + ": " + retentionMode }}</span>
</n-space>
<n-space>
<span>{{ t("Retention RetainUntilDate") + ": " + retainUntilDate }}</span>
</n-space>
</n-descriptions-item>
<!-- <n-descriptions-item label="版本ID">{{ object?.VersionId }}</n-descriptions-item>
<n-descriptions-item label="存储类型">{{ object?.StorageClass }}</n-descriptions-item> -->
@@ -105,6 +108,42 @@
</n-form>
</n-card>
</n-modal>
<n-modal
v-model:show="showRetentionView"
preset="card"
:title="t('Retention') + t('Policy')"
class="max-w-screen-md">
<n-card class="flex-col justify-center items-center">
<n-form
ref="retentionFormRef"
:label-width="200"
label-placement="left"
label-align="left"
class="w-[500px]">
<n-form-item :label="t('Retention Mode')" path="retentionMode" class="flex-auto">
<n-radio-group v-model:value="retentionMode">
<n-radio value="COMPLIANCE">Compliance</n-radio>
<n-radio value="GOVERNANCE">Governance</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item :label="t('Retention RetainUntilDate')" path="retainUntilDate" class="flex-auto">
<n-date-picker
v-model:formatted-value="retainUntilDate"
value-format="yyyy-MM-dd HH:mm:ss"
type="datetime"
clearable />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitRetention">{{ t("Confirm") }}</n-button>
<n-button class="mx-4" @click="resetRetention">{{ t("Reset") }}</n-button>
<n-button @click="showRetentionView = false">{{ t("Cancel") }}</n-button>
</n-form-item>
</n-form>
</n-card>
</n-modal>
</n-card>
</n-drawer-content>
</n-drawer>
@@ -112,6 +151,7 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import dayjs from "dayjs";
const { t } = useI18n();
const visibel = ref(false);
@@ -178,6 +218,7 @@ const getObjectLockConfig = async () => {
// 如果桶开启了object lock,则获取合法保留状态
if (lockStatus.value) {
getObjectLegalHoldFn();
getObjectRetentionFn();
}
} else {
lockStatus.value = false;
@@ -211,7 +252,59 @@ const handleChangeLegalStatus = () => {
});
};
/****************************************************************** */
/**======================================================= */
// 保留相关
const retentionFormRef = ref();
const retentionMode = ref<string | null>(null);
const retainUntilDate = ref<string | null>(null);
console.log("🚀 ~ retainUntilDate:", retainUntilDate.value);
const retentionLoading = ref(false);
const showRetentionView = ref(false);
// 获取保留状态
const getObjectRetentionFn = () => {
const { getObjectRetention } = useObject({
bucket: bucketName.value,
});
getObjectRetention(key.value).then((res) => {
if (res.Retention) {
retentionMode.value = res.Retention.Mode || "";
retainUntilDate.value = res.Retention.RetainUntilDate
? dayjs(res.Retention.RetainUntilDate).format("YYYY-MM-DD HH:mm:ss")
: null;
} else {
retentionMode.value = "";
}
});
};
const submitRetention = () => {
retentionLoading.value = true;
const { putObjectRetention } = useObject({
bucket: bucketName.value,
});
putObjectRetention(key.value, {
Mode: retentionMode.value,
RetainUntilDate: retainUntilDate.value ? new Date(retainUntilDate.value) : null,
})
.then(() => {
message.success(t("Retention Update Success"));
getObjectRetentionFn();
})
.finally(() => {
retentionLoading.value = false;
showRetentionView.value = false;
});
};
const resetRetention = () => {
retentionMode.value = null;
retainUntilDate.value = null;
};
/**======================================================= */
// 删除标签
const handledeleteTag = async (index: number) => {
+70 -41
View File
@@ -38,10 +38,24 @@
</div>
</template>
</n-page-header>
<n-data-table class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="filteredObjects" :row-key="rowKey" @update:checked-row-keys="handleCheck"
:pagination="false" :bordered="false" />
<object-upload-picker :show="uploadPickerVisible" @update:show="(val: any) => (uploadPickerVisible = val && refresh())" :bucketName="bucketName" :prefix="prefix" />
<object-new-form :show="newObjectFormVisible" :asPrefix="newObjectAsPrefix" @update:show="(val: any) => (newObjectFormVisible = val && refresh())" :bucketName="bucketName"
<n-data-table
class="border dark:border-neutral-700 rounded overflow-hidden"
:columns="columns"
:data="filteredObjects"
:row-key="rowKey"
@update:checked-row-keys="handleCheck"
:pagination="false"
:bordered="false" />
<object-upload-picker
:show="uploadPickerVisible"
@update:show="(val: any) => (uploadPickerVisible = val && refresh())"
:bucketName="bucketName"
:prefix="prefix" />
<object-new-form
:show="newObjectFormVisible"
:asPrefix="newObjectAsPrefix"
@update:show="(val: any) => (newObjectFormVisible = val && refresh())"
:bucketName="bucketName"
:prefix="prefix" />
<n-button-group class="ml-auto">
<n-button @click="goToPreviousPage" :disabled="!continuationToken">
@@ -58,15 +72,15 @@
<script setup lang="ts">
const { $s3Client } = useNuxtApp();
import { useAsyncData, useRoute, useRouter } from "#app"
import { NuxtLink } from "#components"
import { ListObjectsV2Command, type _Object, type CommonPrefix } from "@aws-sdk/client-s3"
import dayjs from "dayjs"
import type { DataTableColumns, DataTableRowKey } from 'naive-ui'
import { joinRelativeURL } from "ufo"
import { computed, ref, watch, type VNode } from "vue"
import { useDeleteTaskManagerStore } from "~/store/delete-tasks"
import { useUploadTaskManagerStore } from "~/store/upload-tasks"
import { useAsyncData, useRoute, useRouter } from "#app";
import { NuxtLink } from "#components";
import { ListObjectsV2Command, type _Object, type CommonPrefix } from "@aws-sdk/client-s3";
import dayjs from "dayjs";
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
import { joinRelativeURL } from "ufo";
import { computed, ref, watch, type VNode } from "vue";
import { useDeleteTaskManagerStore } from "~/store/delete-tasks";
import { useUploadTaskManagerStore } from "~/store/upload-tasks";
const route = useRoute();
const router = useRouter();
@@ -77,7 +91,7 @@ const props = defineProps<{ bucket: string; path: string }>();
const uploadPickerVisible = ref(false);
const newObjectFormVisible = ref(false);
const newObjectAsPrefix = ref(false);
const searchTerm = ref('');
const searchTerm = ref("");
// Add debounce function for search
const debounce = (fn: Function, delay: number) => {
@@ -107,8 +121,16 @@ const deleteTaskStore = useDeleteTaskManagerStore();
const deleteTasks = computed(() => deleteTaskStore.tasks);
// 当任务变化时,刷新数据
watch(() => uploadTasks, () => setTimeout(refresh, 500), { deep: true });
watch(() => deleteTasks, () => setTimeout(refresh, 500), { deep: true });
watch(
() => uploadTasks,
() => setTimeout(refresh, 500),
{ deep: true }
);
watch(
() => deleteTasks,
() => setTimeout(refresh, 500),
{ deep: true }
);
// bucketName
const bucketName = computed(() => props.bucket as string);
@@ -139,7 +161,6 @@ interface RowData {
type: "prefix" | "object";
Size: number;
LastModified: string;
}
const infoRef = ref();
@@ -159,13 +180,19 @@ const columns: DataTableColumns<RowData> = [
}
const keyInUri = row.Key;
return h(NuxtLink, {
href: row.type === "prefix" ? bucketPath(keyInUri) : '', class: "block text-cyan-400 cursor-pointer", onClick: (e: MouseEvent) => {
if (row.type === "prefix") return
infoRef.value.openDrawer(bucketName.value, row.Key)
return
}
}, () => label);
return h(
NuxtLink,
{
href: row.type === "prefix" ? bucketPath(keyInUri) : "",
class: "block text-cyan-400 cursor-pointer",
onClick: (e: MouseEvent) => {
if (row.type === "prefix") return;
infoRef.value.openDrawer(bucketName.value, row.Key);
return;
},
},
() => label
);
},
},
{ key: "Size", title: "大小", render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : "") },
@@ -173,7 +200,7 @@ const columns: DataTableColumns<RowData> = [
key: "LastModified",
title: "更新时间",
render: (row: { LastModified: string }) => {
return row.LastModified ? dayjs(row.LastModified).format('YYYY-MM-DD HH:mm:ss') : "";
return row.LastModified ? dayjs(row.LastModified).format("YYYY-MM-DD HH:mm:ss") : "";
},
},
];
@@ -185,14 +212,14 @@ interface ListObjectsResponse {
isTruncated: boolean;
}
const randomString = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const salt = ref(randomString())
const salt = ref(randomString());
// 在服务端获取数据
const { data, refresh } = await useAsyncData<ListObjectsResponse>(
`objectsData-${salt.value}&${prefix.value}&${pageSize.value}&${continuationToken.value}`,
@@ -256,7 +283,7 @@ const filteredObjects = computed(() => {
}
const term = searchTerm.value.toLowerCase();
return objects.value.filter(obj => {
return objects.value.filter((obj) => {
const displayKey = prefix.value ? obj.Key?.substring(prefix.value.length) : obj.Key;
return displayKey?.toLowerCase().includes(term);
});
@@ -287,20 +314,22 @@ function handleBatchDelete() {
return;
}
try {
await Promise.all(checkedKeys.value.map(async (item) => {
const findOne = objects.value.find((obj) => obj.Key === item);
// 目录删除
// 递归查询目录下的所有文件,然后删除
if (findOne?.type === "prefix" && findOne?.Key) {
return await objectApi.mapAllFiles(bucketName.value, findOne.Key, (fileKey: string) => {
deleteTaskStore.addKeys([fileKey], bucketName.value);
});
}
await Promise.all(
checkedKeys.value.map(async (item) => {
const findOne = objects.value.find((obj) => obj.Key === item);
// 目录删除
// 递归查询目录下的所有文件,然后删除
if (findOne?.type === "prefix" && findOne?.Key) {
return await objectApi.mapAllFiles(bucketName.value, findOne.Key, (fileKey: string) => {
deleteTaskStore.addKeys([fileKey], bucketName.value);
});
}
return deleteTaskStore.addKeys([String(item)], bucketName.value);
}));
return deleteTaskStore.addKeys([String(item)], bucketName.value);
})
);
message.success("删除中");
salt.value = randomString()
salt.value = randomString();
refresh();
} catch (error) {
message.error("删除失败");
+13 -2
View File
@@ -131,13 +131,25 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
Key: key,
};
return await $client.send(new GetObjectRetentionCommand(params));
try {
const response = await $client.send(new GetObjectRetentionCommand(params));
if (!response || !response.Retention || Object.keys(response.Retention).length === 0) {
return { Retention: null };
}
return response;
} catch (error: any) {
if (error.message?.includes("Deserialization error")) {
return { Retention: null };
}
throw error;
}
}
async function putObjectRetention(key: string, retention: any) {
const params = {
Bucket: bucket,
Key: key,
Retention: retention,
};
return await $client.send(new PutObjectRetentionCommand(params));
}
@@ -147,7 +159,6 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
Bucket: bucket,
Key: key,
};
console.log("🚀 ~ getObjectLegalHold ~ params:", params);
return await $client.send(new GetObjectLegalHoldCommand(params));
}
+6 -1
View File
@@ -328,5 +328,10 @@
"Please enter retention period": "Please enter retention period",
"Please select retention unit": "Please select retention unit",
"Set Retention": "Set Retention",
"Legal Hold": "Legal Hold"
"Legal Hold": "Legal Hold",
"Retention RetainUntilDate": "Retention RetainUntilDate",
"Retention Update Success": "Retention Update Success",
"Retention Update Failed": "Retention Update Failed",
"Retention RetainUntilDate Placeholder": "Retention RetainUntilDate Placeholder",
"Retention RetainUntilDate Clear": "Retention RetainUntilDate Clear"
}
+7 -2
View File
@@ -204,7 +204,7 @@
"Last Modified": "更新时间",
"Ellipsis": "...",
"Object Details": "对象详情",
"Copy URL": "复制临时链接",
"Copy URL": "复制链接",
"Delete Confirm": "删除对象 {key} ?",
"Info": "对象信息",
"Object Name": "对象名称",
@@ -520,5 +520,10 @@
"Please enter retention period": "请输入保留期限",
"Please select retention unit": "请选择保留单位",
"Set Retention": "设置保留",
"Legal Hold": "合法保留"
"Legal Hold": "合法保留",
"Retention RetainUntilDate": "保留至日期",
"Retention Update Success": "保留更新成功",
"Retention Update Failed": "保留更新失败",
"Retention RetainUntilDate Placeholder": "请输入保留至日期",
"Retention RetainUntilDate Clear": "清除保留至日期"
}
+54 -26
View File
@@ -1,8 +1,16 @@
import { S3Client } from "@aws-sdk/client-s3";
import type { SiteConfig } from "~/types/config";
import type { DeserializeHandler, DeserializeHandlerArguments, DeserializeHandlerOutput } from "@aws-sdk/types";
interface S3Response {
response?: {
body?: string;
};
[key: string]: any;
}
export default defineNuxtPlugin({
name: 's3-client',
name: "s3-client",
setup(nuxtApp) {
const { credentials, isAuthenticated } = useAuth();
const siteConfig = nuxtApp.$siteConfig as SiteConfig;
@@ -11,44 +19,64 @@ export default defineNuxtPlugin({
return;
}
const client = new S3Client({
endpoint: siteConfig.s3.endpoint,
region: siteConfig.s3.region || 'us-east-1',
region: siteConfig.s3.region || "us-east-1",
forcePathStyle: true,
// https://github.com/aws/aws-sdk-js-v3/issues/6834#issuecomment-2611346849
requestChecksumCalculation: "WHEN_REQUIRED",
credentials: {
accessKeyId: credentials.value?.AccessKeyId || '',
secretAccessKey: credentials.value?.SecretAccessKey || '',
sessionToken: credentials.value?.SessionToken || '',
}
accessKeyId: credentials.value?.AccessKeyId || "",
secretAccessKey: credentials.value?.SecretAccessKey || "",
sessionToken: credentials.value?.SessionToken || "",
},
});
// 添加中间件捕捉 401 错误
// 添加中间件处理XML响应和401错误
client.middlewareStack.add(
(next, context) => async (args) => {
try {
return await next(args);
} catch (error: any) {
if (error?.$metadata?.httpStatusCode === 401) {
await useAuth().logoutAndRedirect();
(next: DeserializeHandler<any, any>) =>
async (args: DeserializeHandlerArguments<any>): Promise<DeserializeHandlerOutput<any>> => {
try {
const response = (await next(args)) as S3Response;
// 检查响应是否为XML格式
if (response.response?.body && typeof response.response.body === "string") {
const body = response.response.body.trim();
// 检查是否是空的XML响应
if (body.match(/^<\?xml[^>]*\?><[^>]*><\/[^>]*>$/)) {
// 从XML标签名提取属性名
const tagName = body.match(/<([^>]*)><\/\1>/)?.[1];
if (tagName) {
// 将XML标签名转换为驼峰命名
const propertyName = tagName.replace(/(?:^|_)([a-z])/g, (_, letter) => letter.toUpperCase());
// 返回null,保持response结构
return {
response: response.response,
[propertyName]: null,
} as unknown as DeserializeHandlerOutput<any>;
}
}
}
return response as DeserializeHandlerOutput<any>;
} catch (error: any) {
if (error?.$metadata?.httpStatusCode === 401) {
await useAuth().logoutAndRedirect();
}
throw error;
}
// 其他错误处理
console.error('S3Client error:', error);
throw error;
}
},
},
{
step: "finalizeRequest",
name: "handleUnauthorizedMiddleware",
step: "deserialize",
name: "handleXmlResponse",
}
);
return {
provide: {
s3Client: client
}
}
}
})
s3Client: client,
},
};
},
});