feat: object versions rustfs/rustfs#198

This commit is contained in:
马登山
2025-07-15 18:02:35 +08:00
parent d49f10b379
commit 0448b84001
6 changed files with 546 additions and 214 deletions
+128 -67
View File
@@ -4,22 +4,27 @@
<div class="flex items-center flex-wrap gap-4 ml-auto">
<n-button @click="download">
<Icon name="ri:download-line" class="mr-2" />
<span>{{ t("Download") }}</span>
<span>{{ t('Download') }}</span>
</n-button>
<n-button @click="() => (showPreview = true)">
<Icon name="ri:eye-line" class="mr-2" />
<span>{{ t("Preview") }}</span>
<span>{{ t('Preview') }}</span>
</n-button>
<n-button @click="() => (showTagView = true)">
<Icon name="ri:price-tag-3-line" class="mr-2" />
<span>{{ t("Tags") }}</span>
<span>{{ t('Tags') }}</span>
</n-button>
<n-button @click="geVersions">
<Icon name="ri:file-history-line" class="mr-2" />
<span>{{ t('Version') }}</span>
</n-button>
<n-button v-if="lockStatus" @click="() => (showRetentionView = true)">
<Icon name="ri:save-2-line" class="mr-2" />
<span>{{ t("Retention") }}</span>
<span>{{ t('Retention') }}</span>
</n-button>
<!-- <n-button id="copyTag" ref="copyRef" @click="copySignUrl">
@@ -35,13 +40,19 @@
<n-descriptions-item :label="t('Object Name')">
<span class="select-all">{{ key }}</span>
</n-descriptions-item>
<n-descriptions-item :label="t('Object Size')">{{ object?.ContentLength }}</n-descriptions-item>
<n-descriptions-item :label="t('Object Type')">{{ object?.ContentType }}</n-descriptions-item>
<n-descriptions-item :label="t('Object Size')">{{
object?.ContentLength
}}</n-descriptions-item>
<n-descriptions-item :label="t('Object Type')">{{
object?.ContentType
}}</n-descriptions-item>
<!-- <n-descriptions-item label="存储类型">{{ object?.StorageClass }}</n-descriptions-item> -->
<n-descriptions-item label="ETag">
<span class="select-all">{{ object?.ETag }}</span>
</n-descriptions-item>
<n-descriptions-item :label="t('Last Modified Time')">{{ object?.LastModified }}</n-descriptions-item>
<n-descriptions-item :label="t('Last Modified Time')">{{
object?.LastModified
}}</n-descriptions-item>
<!-- 合法保留 -->
<n-descriptions-item :label="t('Legal Hold')">
@@ -51,16 +62,17 @@
v-model:value="legalHold"
:loading="legalHoldLoading"
:round="false"
@update:value="handleChangeLegalStatus">
@update:value="handleChangeLegalStatus"
>
<template #checked>
{{ t("Enabled") }}
{{ t('Enabled') }}
</template>
<template #unchecked>
{{ t("Disabled") }}
{{ t('Disabled') }}
</template>
</n-switch>
<n-tag type="error" size="small" v-else class="mt-2">
{{ t("Disabled") }}
{{ t('Disabled') }}
<template #icon>
<Icon name="ri:close-circle-fill" />
</template>
@@ -70,10 +82,12 @@
<!-- 保留 -->
<n-descriptions-item :label="t('Retention') + t('Policy')">
<n-space>
<span>{{ t("Retention Mode") + ": " + (retentionMode ? retentionMode : t("None")) }}</span>
<span>{{
t('Retention Mode') + ': ' + (retentionMode ? retentionMode : t('None'))
}}</span>
</n-space>
<n-space v-if="retainUntilDate">
<span>{{ t("Retention RetainUntilDate") + ": " + retainUntilDate }}</span>
<span>{{ t('Retention RetainUntilDate') + ': ' + retainUntilDate }}</span>
</n-space>
</n-descriptions-item>
@@ -82,15 +96,28 @@
<n-descriptions-item :label="t('Temporary URL')">
<div class="flex items-center gap-2 mt-1">
<n-input v-model:value="signedUrl" id="signedUrl" :placeholder="t('Temporary URL')" />
<n-button id="signedUrlBtn" data-clipboard-target="#signedUrl">{{ t("Copy") }}</n-button>
<n-button id="signedUrlBtn" data-clipboard-target="#signedUrl">{{
t('Copy')
}}</n-button>
</div>
</n-descriptions-item>
</n-descriptions>
<object-preview-modal v-model:show="showPreview" :bucketName="bucketName" :objectKey="key" />
<object-preview-modal
v-model:show="showPreview"
:bucketName="bucketName"
:objectKey="key"
:versionId="previewVersionId"
/>
<!-- tagview -->
<n-modal v-model:show="showTagView" preset="card" :title="t('Set Tags')" draggable class="max-w-screen-md">
<n-modal
v-model:show="showTagView"
preset="card"
:title="t('Set Tags')"
draggable
class="max-w-screen-md"
>
<n-card class="max-w-screen-md">
<n-space class="my-4">
<n-tag
@@ -98,7 +125,8 @@
v-for="(tag, index) in tags"
type="info"
closable
@close="handledeleteTag(index)">
@close="handledeleteTag(index)"
>
{{ tag.Key }}:{{ tag.Value }}
</n-tag>
</n-space>
@@ -107,11 +135,14 @@
<n-input v-model:value="tagFormValue.Key" :placeholder="t('Tag Key Placeholder')" />
</n-form-item>
<n-form-item :label="t('Tag Value')" path="Value">
<n-input v-model:value="tagFormValue.Value" :placeholder="t('Tag Value Placeholder')" />
<n-input
v-model:value="tagFormValue.Value"
:placeholder="t('Tag Value Placeholder')"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitTagForm">{{ t("Add") }}</n-button>
<n-button class="mx-4" @click="showTagView = false">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="submitTagForm">{{ t('Add') }}</n-button>
<n-button class="mx-4" @click="showTagView = false">{{ t('Cancel') }}</n-button>
</n-form-item>
</n-form>
</n-card>
@@ -121,33 +152,40 @@
v-model:show="showRetentionView"
preset="card"
:title="t('Retention') + t('Policy')"
class="max-w-screen-md">
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]">
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">{{ t("COMPLIANCE") }}</n-radio>
<n-radio value="GOVERNANCE">{{ t("GOVERNANCE") }}</n-radio>
<n-radio value="COMPLIANCE">{{ t('COMPLIANCE') }}</n-radio>
<n-radio value="GOVERNANCE">{{ t('GOVERNANCE') }}</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item :label="t('Retention RetainUntilDate')" path="retainUntilDate" class="flex-auto">
<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 />
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-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>
@@ -155,16 +193,24 @@
</n-card>
</n-drawer-content>
</n-drawer>
<VersionsModal
:bucketName="bucketName"
:objectKey="key"
:visible="showVersions"
@close="showVersions = false"
@preview="handlePreviewVersion"
/>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import dayjs from "dayjs";
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import VersionsModal from './versions.vue';
const { t } = useI18n();
const visibel = ref(false);
const bucketName = ref("");
const key = ref("");
const bucketName = ref('');
const key = ref('');
let objectApi: any;
const openDrawer = (bucket: string, objName: string) => {
@@ -188,8 +234,8 @@ const showPreview = ref(false);
// 标签
const showTagView = ref(false);
const tagFormValue = ref({
Key: "",
Value: "",
Key: '',
Value: '',
});
interface Tag {
Key: string;
@@ -220,9 +266,9 @@ const legalHoldLoading = ref(false);
// 获取桶的object lock
const getObjectLockConfig = async () => {
const { getObjectLockConfiguration } = useBucket({});
getObjectLockConfiguration(bucketName.value).then((res) => {
getObjectLockConfiguration(bucketName.value).then(res => {
if (res.ObjectLockConfiguration?.ObjectLockEnabled) {
lockStatus.value = res.ObjectLockConfiguration?.ObjectLockEnabled == "Enabled" ? true : false;
lockStatus.value = res.ObjectLockConfiguration?.ObjectLockEnabled == 'Enabled' ? true : false;
// 如果桶开启了object lock,则获取合法保留状态
if (lockStatus.value) {
getObjectLegalHoldFn();
@@ -241,8 +287,8 @@ const getObjectLegalHoldFn = () => {
bucket: bucketName.value,
});
getObjectLegalHold(key.value)
.then((res) => {
legalHold.value = res.LegalHold?.Status == "ON" ? true : false;
.then(res => {
legalHold.value = res.LegalHold?.Status == 'ON' ? true : false;
})
.finally(() => {
legalHoldLoading.value = false;
@@ -254,7 +300,7 @@ const handleChangeLegalStatus = () => {
bucket: bucketName.value,
});
putObjectLegalHold(key.value, {
Status: legalHold.value ? "ON" : "OFF",
Status: legalHold.value ? 'ON' : 'OFF',
}).finally(() => {
getObjectLegalHoldFn();
});
@@ -266,7 +312,7 @@ const retentionFormRef = ref();
const retentionMode = ref<string | null>(null);
const retainUntilDate = ref<string | null>(null);
console.log("🚀 ~ retainUntilDate:", retainUntilDate.value);
console.log('🚀 ~ retainUntilDate:', retainUntilDate.value);
const retentionLoading = ref(false);
const showRetentionView = ref(false);
@@ -275,14 +321,14 @@ const getObjectRetentionFn = () => {
const { getObjectRetention } = useObject({
bucket: bucketName.value,
});
getObjectRetention(key.value).then((res) => {
getObjectRetention(key.value).then(res => {
if (res.Retention) {
retentionMode.value = res.Retention.Mode || "";
retentionMode.value = res.Retention.Mode || '';
retainUntilDate.value = res.Retention.RetainUntilDate
? dayjs(res.Retention.RetainUntilDate).format("YYYY-MM-DD HH:mm:ss")
? dayjs(res.Retention.RetainUntilDate).format('YYYY-MM-DD HH:mm:ss')
: null;
} else {
retentionMode.value = "";
retentionMode.value = '';
}
});
};
@@ -298,7 +344,7 @@ const submitRetention = () => {
RetainUntilDate: retainUntilDate.value ? new Date(retainUntilDate.value) : null,
})
.then(() => {
message.success(t("Retention Update Success"));
message.success(t('Retention Update Success'));
getObjectRetentionFn();
})
.finally(() => {
@@ -318,18 +364,20 @@ const resetRetention = () => {
const handledeleteTag = async (index: number) => {
const { putObjectTagging } = useObject({ bucket: bucketName.value });
dialog.error({
title: t("Warning"),
content: t("Delete Tag Confirm"),
positiveText: t("Confirm"),
negativeText: t("Cancel"),
title: t('Warning'),
content: t('Delete Tag Confirm'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
putObjectTagging(key.value, { TagSet: tags.value.filter((item, keyIndex) => keyIndex !== index) })
putObjectTagging(key.value, {
TagSet: tags.value.filter((item, keyIndex) => keyIndex !== index),
})
.then(() => {
message.success(t("Tag Update Success"));
message.success(t('Tag Update Success'));
getTags();
})
.catch((error) => {
message.error(t("Tag Delete Failed", { error: error.message }));
.catch(error => {
message.error(t('Tag Delete Failed', { error: error.message }));
});
},
});
@@ -344,19 +392,19 @@ const submitTagForm = async () => {
putObjectTagging(key.value, {
TagSet: [...tags.value, tag],
}).then(() => {
message.success(t("Tag Set Success"));
message.success(t('Tag Set Success'));
getTags();
});
};
const object = ref();
const loading = ref("pending");
const signedUrl = ref("");
const loading = ref('pending');
const signedUrl = ref('');
const getObject = () => {
objectApi.headObject(key.value).then(async (res: any) => {
object.value = res;
loading.value = "fulfilled";
loading.value = 'fulfilled';
signedUrl.value = await objectApi.getSignedUrl(key.value);
});
};
@@ -364,24 +412,23 @@ const getObject = () => {
const refreshSignedUrl = async () => {
try {
signedUrl.value = await objectApi.getSignedUrl(key.value);
message.success(t("Get Success"));
message.success(t('Get Success'));
} catch (error) {
message.error(t("Get Failed"));
message.error(t('Get Failed'));
} finally {
}
};
const dialog = useDialog();
// 复制
import ClipboardJS from "clipboard";
const clipboard = new ClipboardJS( "#signedUrlBtn");
clipboard.on('success', function(e) {
import ClipboardJS from 'clipboard';
const clipboard = new ClipboardJS('#signedUrlBtn');
clipboard.on('success', function (e) {
message.success(t('Copy Success'));
e.clearSelection();
});
// 这里成功的时候也响应error,所以这里也加上
clipboard.on('error', function(e) {
clipboard.on('error', function (e) {
message.success(t('Copy Success'));
});
onUnmounted(() => {
@@ -389,10 +436,24 @@ onUnmounted(() => {
});
const download = async () => {
const msg = message.loading(t("Getting URL"));
const msg = message.loading(t('Getting URL'));
await refreshSignedUrl();
const url = await objectApi.getSignedUrl(key.value);
msg.destroy();
window.open(url, "_blank");
window.open(url, '_blank');
};
// 版本
const showVersions = ref(false);
const previewVersionId = ref<string | null>(null);
const geVersions = async () => {
showVersions.value = true;
};
function handlePreviewVersion(versionId: string) {
previewVersionId.value = versionId;
showPreview.value = true;
}
</script>
+1
View File
@@ -278,6 +278,7 @@ watch(
);
const contents = computed(() => data.value?.contents || []);
console.log('🚀 ~ contents:', contents.value);
const commonPrefixes = computed(() => data.value?.commonPrefixes || []);
const nextToken = computed(() => data.value?.nextContinuationToken || null);
+175 -84
View File
@@ -13,10 +13,19 @@
</div>
</template>
<n-spin v-if="loading" size="large"></n-spin>
<div v-else class="min-h-64 max-h-[80vh] overflow-y-auto flex-1 flex flex-col items-center" :class="{ 'justify-center': !canPreview }">
<div
v-else
class="min-h-64 max-h-[80vh] overflow-y-auto flex-1 flex flex-col items-center"
:class="{ 'justify-center': !canPreview }"
>
<template v-if="canPreview">
<img v-if="isImage" :src="previewUrl" alt="preview" />
<iframe v-else-if="isPdf" :src="previewUrl" class="w-full min-h-[70vh]" frameborder="0"></iframe>
<iframe
v-else-if="isPdf"
:src="previewUrl"
class="w-full min-h-[70vh]"
frameborder="0"
></iframe>
<pre v-else-if="isText" class="w-full selea">{{ fileContent }}</pre>
<video v-else-if="isVideo" controls class="w-full">
<source :src="previewUrl" type="video/mp4" />
@@ -39,87 +48,144 @@
import { useMessage } from 'naive-ui';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl as _getSignedUrl } from '@aws-sdk/s3-request-presigner';
const { t } = useI18n()
const { t } = useI18n();
const props = defineProps<{
show: boolean
bucketName: string
objectKey: string
}>()
show: boolean;
bucketName: string;
objectKey: string;
versionId?: string | null;
}>();
const emit = defineEmits(['update:show'])
const { getSignedUrl, headObject } = useObject({ bucket: props.bucketName })
const emit = defineEmits(['update:show']);
const { getSignedUrl, headObject } = useObject({ bucket: props.bucketName });
// 内部状态
const previewUrl = ref<string | undefined>(undefined)
const contentType = ref<string | undefined>(undefined)
const fileContent = ref<string | undefined>(undefined)
const loading = ref<boolean>(false)
const previewUrl = ref<string | undefined>(undefined);
const contentType = ref<string | undefined>(undefined);
const fileContent = ref<string | undefined>(undefined);
const loading = ref<boolean>(false);
const message = useMessage()
const message = useMessage();
// 文件类型配置
const fileTypeConfig = {
text: {
mimes: [
'application/json', 'application/xml',
'application/javascript', 'application/x-sh',
'application/x-csh', 'application/x-python',
'application/x-php', 'application/x-perl',
'application/x-shellscript', 'application/x-ruby',
'application/x-java', 'application/x-cpp',
'application/json',
'application/xml',
'application/javascript',
'application/x-sh',
'application/x-csh',
'application/x-python',
'application/x-php',
'application/x-perl',
'application/x-shellscript',
'application/x-ruby',
'application/x-java',
'application/x-cpp',
],
extensions: [
'.txt', '.json', '.xml', '.js', '.sh', '.csh',
'.py', '.php', '.pl', '.sh', '.rb', '.java',
'.cpp', '.h', '.hpp', '.c', '.cc', '.hh',
'.hxx', '.cxx', '.java', '.kt', '.scala',
'.groovy', '.rs', '.go', '.dart', '.swift',
'.m', '.mm', '.cs', '.ts', '.jsx', '.tsx',
'.html', '.htm', '.css', '.scss', '.sass',
'.less', '.styl', '.yaml', '.yml', '.toml',
'.ini', '.cfg', '.conf', '.properties', '.md',
'.markdown', '.rst', '.r',
]
'.txt',
'.json',
'.xml',
'.js',
'.sh',
'.csh',
'.py',
'.php',
'.pl',
'.sh',
'.rb',
'.java',
'.cpp',
'.h',
'.hpp',
'.c',
'.cc',
'.hh',
'.hxx',
'.cxx',
'.java',
'.kt',
'.scala',
'.groovy',
'.rs',
'.go',
'.dart',
'.swift',
'.m',
'.mm',
'.cs',
'.ts',
'.jsx',
'.tsx',
'.html',
'.htm',
'.css',
'.scss',
'.sass',
'.less',
'.styl',
'.yaml',
'.yml',
'.toml',
'.ini',
'.cfg',
'.conf',
'.properties',
'.md',
'.markdown',
'.rst',
'.r',
],
},
image: {
extensions: [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg',
]
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'],
},
audio: {
extensions: [
'.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma',
]
extensions: ['.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma'],
},
pdf: {
extensions: [
'.pdf',
]
}
extensions: ['.pdf'],
},
};
// 文件类型判断函数
function isFileType(contentType: string | undefined, fileName: string, type: keyof typeof fileTypeConfig): boolean {
function isFileType(
contentType: string | undefined,
fileName: string,
type: keyof typeof fileTypeConfig
): boolean {
if (type === 'text') {
return contentType?.startsWith('text/') ||
return (
contentType?.startsWith('text/') ||
fileTypeConfig.text.mimes.some(mime => contentType?.includes(mime)) ||
fileTypeConfig.text.extensions.some(ext => fileName.endsWith(ext));
fileTypeConfig.text.extensions.some(ext => fileName.endsWith(ext))
);
}
if (type === 'image') {
return contentType?.startsWith('image/') ||
fileTypeConfig.image.extensions.some(ext => fileName.endsWith(ext));
return (
contentType?.startsWith('image/') ||
fileTypeConfig.image.extensions.some(ext => fileName.endsWith(ext))
);
}
if (type === 'audio') {
return contentType?.startsWith('audio/') ||
fileTypeConfig.audio.extensions.some(ext => fileName.endsWith(ext));
return (
contentType?.startsWith('audio/') ||
fileTypeConfig.audio.extensions.some(ext => fileName.endsWith(ext))
);
}
if (type === 'pdf') {
return contentType === 'application/pdf' ||
fileTypeConfig.pdf.extensions.some(ext => fileName.endsWith(ext));
return (
contentType === 'application/pdf' ||
fileTypeConfig.pdf.extensions.some(ext => fileName.endsWith(ext))
);
}
return false;
@@ -132,62 +198,87 @@ const isAudio = computed(() => isFileType(contentType.value, props.objectKey, 'a
const isPdf = computed(() => isFileType(contentType.value, props.objectKey, 'pdf'));
const isText = computed(() => isFileType(contentType.value, props.objectKey, 'text'));
const canPreview = computed(() => isImage.value || isPdf.value || isText.value || isVideo.value || isAudio.value);
const canPreview = computed(
() => isImage.value || isPdf.value || isText.value || isVideo.value || isAudio.value
);
// 当 show 为 true 时触发加载逻辑
watch(() => props.show, async (newVal) => {
if (newVal) {
await loadPreview()
} else {
resetState()
watch(
() => props.show,
async newVal => {
if (newVal) {
await loadPreview();
} else {
resetState();
}
}
})
);
async function loadPreview() {
loading.value = true
previewUrl.value = undefined
fileContent.value = undefined
contentType.value = undefined
loading.value = true;
previewUrl.value = undefined;
fileContent.value = undefined;
contentType.value = undefined;
try {
// 获取预签名URL
const url = await getSignedUrl(props.objectKey)
// 获取预签名URL,支持 versionId
let url: string;
if (props.versionId) {
const $client = useNuxtApp().$s3Client;
const command = new GetObjectCommand({
Bucket: props.bucketName,
Key: props.objectKey,
VersionId: props.versionId,
});
url = await _getSignedUrl($client, command, { expiresIn: 3600 });
} else {
url = await getSignedUrl(props.objectKey);
}
previewUrl.value = url;
previewUrl.value = url
// 先使用 HEAD 请求获取 Content-Type
const response = await headObject(props.objectKey)
const ctype = response?.ContentType
contentType.value = ctype || 'application/octet-stream'
// 先使用 HEAD 请求获取 Content-Type,支持 versionId
let response: any;
if (props.versionId) {
const $client = useNuxtApp().$s3Client;
response = await $client.send(
new HeadObjectCommand({
Bucket: props.bucketName,
Key: props.objectKey,
VersionId: props.versionId,
})
);
} else {
response = await headObject(props.objectKey);
}
const ctype = response?.ContentType;
contentType.value = ctype || 'application/octet-stream';
// 根据 MIME 类型决定是否加载内容
if (isText.value) {
// 加载文本内容
const response = await fetch(url)
const text = await response.text()
fileContent.value = text
const response = await fetch(url);
const text = await response.text();
fileContent.value = text;
}
} catch (err) {
console.error(err)
message.error(t('Preview Failed'))
closeModal()
console.error(err);
message.error(t('Preview Failed'));
closeModal();
} finally {
loading.value = false
loading.value = false;
}
}
function resetState() {
previewUrl.value = undefined
fileContent.value = undefined
contentType.value = undefined
loading.value = false
previewUrl.value = undefined;
fileContent.value = undefined;
contentType.value = undefined;
loading.value = false;
}
function closeModal() {
emit('update:show', false)
emit('update:show', false);
}
const objectKey = computed(() => props.objectKey)
const objectKey = computed(() => props.objectKey);
</script>
+138
View File
@@ -0,0 +1,138 @@
<template>
<n-modal
:show="visible"
@update:show="emit('close')"
preset="card"
:title="t('Object Versions')"
class="max-w-screen-md"
>
<n-data-table :columns="columns" :data="versions" :pagination="pagination" />
<n-button class="mt-4" @click="$emit('close')">{{ t('Close') }}</n-button>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, h } from 'vue';
import { useI18n } from 'vue-i18n';
import { useObject } from '@/composables/useObject';
import { useMessage } from 'naive-ui';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl as _getSignedUrl } from '@aws-sdk/s3-request-presigner';
import dayjs from 'dayjs';
const props = defineProps<{ bucketName: string; objectKey: string; visible: boolean }>();
const emit = defineEmits(['close', 'preview']);
const { t } = useI18n();
const message = useMessage();
const versions = ref<any[]>([]);
const pagination = ref({ page: 1, pageSize: 10 });
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function shortVersionId(versionId: string): string {
if (!versionId) return '';
// 以 - . / 分割,取最后一段
const parts = versionId.split(/[-./]/);
return parts[parts.length - 1];
}
const columns = [
{
title: t('VersionId'),
key: 'VersionId',
render(row: any) {
return shortVersionId(row.VersionId);
},
},
{
title: t('LastModified'),
key: 'LastModified',
render(row: any) {
return row.LastModified ? dayjs(row.LastModified).format('YYYY-MM-DD HH:mm:ss') : '';
},
},
{
title: t('Size'),
key: 'Size',
render(row: any) {
return typeof row.Size === 'number' ? formatBytes(row.Size) : '';
},
},
{
title: t('Action'),
key: 'action',
render(row: any) {
return [
h(
'n-button',
{
size: 'small',
style: 'margin-right: 8px;',
onClick: () => previewVersion(row),
},
t('Preview')
),
h(
'n-button',
{
size: 'small',
type: 'primary',
onClick: () => downloadVersion(row),
},
t('Download')
),
];
},
},
];
watch(
() => props.visible,
val => {
if (val) fetchVersions();
}
);
async function fetchVersions() {
const { getObjectVersions } = useObject({ bucket: props.bucketName });
try {
const { Versions } = await getObjectVersions(props.objectKey);
versions.value = Versions || [];
} catch (e: any) {
message.error(t('Failed to fetch versions'));
}
}
async function previewVersion(row: any) {
// 触发父组件预览弹窗,传递 versionId
emit('preview', row.VersionId);
}
// 新增:自定义带 versionId 的签名 URL 获取方法
async function getSignedUrlWithVersion(key: string, versionId: string, expiresIn = 3600) {
// 直接用 useObject 里的 bucketName
const $client = useNuxtApp().$s3Client;
const command = new GetObjectCommand({
Bucket: props.bucketName,
Key: key,
VersionId: versionId,
});
return await _getSignedUrl($client, command, { expiresIn });
}
async function downloadVersion(row: any) {
try {
const url = await getSignedUrlWithVersion(props.objectKey, row.VersionId);
window.open(url, '_blank');
} catch (e: any) {
message.error(t('Download failed'));
}
}
</script>
+77 -55
View File
@@ -15,12 +15,12 @@
<span>{{ t('Copy URL') }}</span>
</n-button>
<n-button @click="() => showPreview = true">
<n-button @click="() => (showPreview = true)">
<Icon name="ri:eye-line" class="mr-2" />
<span>{{ t('Preview') }}</span>
</n-button>
<n-button @click="() => showTagView = true">
<n-button @click="() => (showTagView = true)">
<Icon name="ri:price-tag-3-line" class="mr-2" />
<span>{{ t('Tags') }}</span>
</n-button>
@@ -47,21 +47,41 @@
<n-spin size="small" />
</div>
<n-descriptions :column="1">
<n-descriptions-item :label="t('Object Name')"><span class="select-all">{{ key }}</span></n-descriptions-item>
<n-descriptions-item :label="t('Object Size')">{{ object?.ContentLength }}</n-descriptions-item>
<n-descriptions-item :label="t('Object Name')"
><span class="select-all">{{ key }}</span></n-descriptions-item
>
<n-descriptions-item :label="t('Object Size')">{{
object?.ContentLength
}}</n-descriptions-item>
<n-descriptions-item :label="t('Object Type')">{{ object?.ContentType }}</n-descriptions-item>
<!-- <n-descriptions-item label="存储类型">{{ object?.StorageClass }}</n-descriptions-item> -->
<n-descriptions-item label="ETag"><span class="select-all">{{ object?.ETag }}</span></n-descriptions-item>
<n-descriptions-item :label="t('Last Modified Time')">{{ object?.LastModified }}</n-descriptions-item>
<n-descriptions-item label="ETag"
><span class="select-all">{{ object?.ETag }}</span></n-descriptions-item
>
<n-descriptions-item :label="t('Last Modified Time')">{{
object?.LastModified
}}</n-descriptions-item>
</n-descriptions>
<object-preview-modal v-model:show="showPreview" :bucketName="bucketName" :objectKey="key" />
<!-- tagview -->
<n-modal v-model:show="showTagView" preset="card" :title="t('Set Tags')" draggable class="max-w-screen-md">
<n-modal
v-model:show="showTagView"
preset="card"
:title="t('Set Tags')"
draggable
class="max-w-screen-md"
>
<n-card class="max-w-screen-md">
<n-space class="my-4">
<n-tag class="m-2 align-middle" v-for="(tag, index) in tags" type="info" closable @close="handledeleteTag(index)">
<n-tag
class="m-2 align-middle"
v-for="(tag, index) in tags"
type="info"
closable
@close="handledeleteTag(index)"
>
{{ tag.Key }}:{{ tag.Value }}
</n-tag>
</n-space>
@@ -86,87 +106,91 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n()
const router = useRouter()
const { t } = useI18n();
const router = useRouter();
const { $s3Client } = useNuxtApp();
const message = useMessage();
const props = defineProps<{ bucket: string; objectKey: string }>()
const props = defineProps<{ bucket: string; objectKey: string }>();
const bucketName = computed(() => props.bucket as string)
const { getObjectTagging, putObjectTagging, deleteObjectTagging } = useObject({ bucket: bucketName.value })
const bucketName = computed(() => props.bucket as string);
const { getObjectTagging, putObjectTagging, deleteObjectTagging } = useObject({
bucket: bucketName.value,
});
// 当前路径的前缀, example: '/folder1/folder2/'
const key = computed(() => decodeURIComponent(props.objectKey as string))
const key = computed(() => decodeURIComponent(props.objectKey as string));
// 预览内容
const showPreview = ref(false)
const showPreview = ref(false);
// 标签
const showTagView = ref(false)
const showTagView = ref(false);
const tagFormValue = ref({
Key: '',
Value: ''
})
Value: '',
});
interface Tag {
Key: string
Value: string
Key: string;
Value: string;
}
// 获取tags
const tags = ref<Tag[]>([])
const tags = ref<Tag[]>([]);
const getTags = async () => {
const resp: any = await getObjectTagging(key.value)
tags.value = resp.TagSet || []
const resp: any = await getObjectTagging(key.value);
tags.value = resp.TagSet || [];
};
getTags();
}
getTags()
const dialog = useDialog()
const dialog = useDialog();
// 删除标签
const handledeleteTag = async (index: number) => {
dialog.error({
title: t("Warning"),
content: t("Delete Tag Confirm"),
positiveText: t("Confirm"),
negativeText: t("Cancel"),
title: t('Warning'),
content: t('Delete Tag Confirm'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
putObjectTagging(key.value, { TagSet: tags.value.filter((item, keyIndex) => keyIndex !== index) })
putObjectTagging(key.value, {
TagSet: tags.value.filter((item, keyIndex) => keyIndex !== index),
})
.then(() => {
message.success(t("Tag Update Success"))
getTags()
})
.catch((error) => {
message.error(t("Tag Delete Failed", { error: error.message }))
message.success(t('Tag Update Success'));
getTags();
})
.catch(error => {
message.error(t('Tag Delete Failed', { error: error.message }));
});
},
});
}
};
const submitTagForm = async () => {
const tag = {
Key: tagFormValue.value.Key,
Value: tagFormValue.value.Value
}
Value: tagFormValue.value.Value,
};
putObjectTagging(key.value, {
TagSet: [...tags.value, tag]
TagSet: [...tags.value, tag],
}).then(() => {
message.success(t('Tag Set Success'))
getTags()
})
}
message.success(t('Tag Set Success'));
getTags();
});
};
const objectApi = useObject({ bucket: bucketName.value });
// 在服务端获取数据
const { data: object, status, refresh } = useAsyncData(`head-object&${key}`, () => objectApi.headObject(key.value))
const {
data: object,
status,
refresh,
} = useAsyncData(`head-object&${key}`, () => objectApi.headObject(key.value));
const download = async () => {
const msg = message.loading(t('Getting URL'));
const url = await objectApi.getSignedUrl(key.value);
msg.destroy();
window.open(url, '_blank')
}
window.open(url, '_blank');
};
const copySignedUrl = async () => {
const msg = message.loading(t('Getting URL'));
@@ -174,12 +198,10 @@ const copySignedUrl = async () => {
await navigator.clipboard.writeText(url);
msg.destroy();
message.success(t('URL Copied'));
}
};
const deleteObject = async () => {
await objectApi.deleteObject(key.value);
message.success(t('Delete Success'));
router.back();
}
};
</script>
+27 -8
View File
@@ -11,8 +11,9 @@ import {
PutObjectRetentionCommand,
GetObjectLegalHoldCommand,
PutObjectLegalHoldCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl as _getSignedUrl } from "@aws-sdk/s3-request-presigner";
ListObjectVersionsCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl as _getSignedUrl } from '@aws-sdk/s3-request-presigner';
export function useObject({ bucket, region }: { bucket: string; region?: string }) {
const $client = useNuxtApp().$s3Client;
@@ -54,18 +55,26 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
return await $client.send(new DeleteObjectCommand(params));
};
const listObject = async (bucket: string, prefix: string | undefined = undefined, pageSize: number = 25) => {
const listObject = async (
bucket: string,
prefix: string | undefined = undefined,
pageSize: number = 25
) => {
const params = {
Bucket: bucket,
Prefix: prefix,
MaxKeys: pageSize,
Delimiter: "/",
Delimiter: '/',
};
return await $client.send(new ListObjectsV2Command(params));
};
async function mapAllFiles(bucketName: string, prefix: string, callback: (fileKey: string) => void) {
async function mapAllFiles(
bucketName: string,
prefix: string,
callback: (fileKey: string) => void
) {
let isTruncated = true;
let continuationToken: string | undefined = undefined;
@@ -82,7 +91,7 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
try {
const data = await $client.send(new ListObjectsV2Command(params));
data.Contents?.forEach((item) => {
data.Contents?.forEach(item => {
if (item.Key) {
callback(item.Key);
}
@@ -91,7 +100,7 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
isTruncated = data.IsTruncated || false;
continuationToken = data.NextContinuationToken;
} catch (error) {
console.error("Error listing files: ", error);
console.error('Error listing files: ', error);
throw error;
}
}
@@ -138,7 +147,7 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
}
return response;
} catch (error: any) {
if (error.message?.includes("Deserialization error")) {
if (error.message?.includes('Deserialization error')) {
return { Retention: null };
}
throw error;
@@ -173,6 +182,15 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
return await $client.send(new PutObjectLegalHoldCommand(params));
}
async function getObjectVersions(key: string) {
const params = {
Bucket: bucket,
Prefix: key,
};
return await $client.send(new ListObjectVersionsCommand(params));
}
return {
headObject,
putObject,
@@ -187,5 +205,6 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
putObjectRetention,
getObjectLegalHold,
putObjectLegalHold,
getObjectVersions,
};
}