Merge pull request #4 from rustfs/feat/resume

Feat/resume
This commit is contained in:
cxymds
2025-07-15 15:58:41 +08:00
committed by GitHub
20 changed files with 964 additions and 674 deletions
+180 -117
View File
@@ -1,25 +1,25 @@
<template>
<n-drawer v-model:show="visibel" :width="502">
<n-drawer-content :title="t('Bucket Configuration')+`(${bucketName})`" closable>
<n-drawer-content :title="t('Bucket Configuration') + `(${bucketName})`" closable>
<n-descriptions :column="2" label-placement="top" bordered label-class="w-1/2">
<n-descriptions-item>
<template #label>
<span>{{ t("Access Policy") }}</span>
<span>{{ t('Access Policy') }}</span>
<n-button class="align-middle" quaternary round type="primary" @click="editPolicy">
<Icon name="ri:edit-2-line" class="mr-2" />
</n-button>
</template>
{{ policyOptions.find((item) => item.value === bucketPolicy)?.label }}
{{ policyOptions.find(item => item.value === bucketPolicy)?.label }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<span class="mr-2">{{ t("Encryption") }}</span>
<span class="mr-2">{{ t('Encryption') }}</span>
<n-button class="align-middle" quaternary round type="primary" @click="editEncript">
<Icon name="ri:edit-2-line" class="mr-2" />
</n-button>
</template>
{{ t("Disabled") }}
{{ t('Disabled') }}
</n-descriptions-item>
<!-- <n-descriptions-item>
<template #label>
@@ -32,7 +32,7 @@
</n-descriptions-item> -->
<n-descriptions-item class="w-1/2">
<template #label>
{{ t("Tag") }}
{{ t('Tag') }}
<n-button class="align-middle" round quaternary type="primary" @click="addTag">
<Icon name="ri:add-line" size="16" class="mr-2" />
</n-button>
@@ -43,23 +43,25 @@
type="info"
@click="editTag(index)"
closable
@close="handledeleteTag(index)">
@close="handledeleteTag(index)"
>
{{ tag.Key }}:{{ tag.Value }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item>
<template #label>{{ t("Object Lock") }}</template>
<template #label>{{ t('Object Lock') }}</template>
<n-switch
:disabled="true"
v-model:value="lockStatus"
:loading="objectLockLoading"
:round="false"
@update:value="handleChangeVersionStatus" />
@update:value="handleChangeVersionStatus"
/>
</n-descriptions-item>
<n-descriptions-item>
<template #label>{{ t("Version Control") }}</template>
<template #label>{{ t('Version Control') }}</template>
<n-switch
v-model:value="versioningStatus"
:disabled="lockStatus == true"
@@ -67,39 +69,40 @@
unchecked-value="Suspended"
:round="false"
:loading="statusLoading"
@update:value="handleChangeVersionStatus" />
@update:value="handleChangeVersionStatus"
/>
</n-descriptions-item>
</n-descriptions>
<!-- retention -->
<n-descriptions :column="2" label-placement="top" bordered label-class="w-1/2">
<n-descriptions-item>
<template #label>{{ t("Retention") }}</template>
<template #label>{{ t('Retention') }}</template>
<n-tag type="success" size="small" v-if="retentionEnabled" @click="editRetention">
{{ t("Enabled") }}
{{ t('Enabled') }}
<template #icon>
<Icon name="ri:checkbox-circle-fill" />
</template>
</n-tag>
<n-tag type="error" size="small" v-else @click="editRetention">
{{ t("Disabled") }}
{{ t('Disabled') }}
<template #icon>
<Icon name="ri:close-circle-fill" />
</template>
</n-tag>
</n-descriptions-item>
<n-descriptions-item>
<template #label>{{ t("Retention Mode") }}</template>
{{ t(retentionFormValue.retentionMode || "") }}
<template #label>{{ t('Retention Mode') }}</template>
{{ t(retentionFormValue.retentionMode || '') }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>{{ t("Retention Unit") }}</template>
{{ t(retentionFormValue.retentionUnit || "") }}
<template #label>{{ t('Retention Unit') }}</template>
{{ t(retentionFormValue.retentionUnit || '') }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>{{ t("Retention Period") }}</template>
{{ retentionFormValue.retentionPeriod || "" }}
<template #label>{{ t('Retention Period') }}</template>
{{ retentionFormValue.retentionPeriod || '' }}
</n-descriptions-item>
</n-descriptions>
</n-drawer-content>
@@ -110,40 +113,56 @@
:title="t('Set Policy')"
preset="card"
draggable
:style="{ width: '750px' }">
:style="{ width: '750px' }"
>
<n-form
ref="policyFormRef"
:inline="policyFormValue.policy !== 'custom'"
:label-width="80"
:model="policyFormValue">
:model="policyFormValue"
>
<n-form-item :label="t('Policy')" path="" class="flex-auto">
<n-select
v-model:value="policyFormValue.policy"
:placeholder="t('Please select policy')"
:options="policyOptions" />
:options="policyOptions"
/>
</n-form-item>
<n-form-item :span="24" v-if="policyFormValue.policy == 'custom'" :label="t('Policy Content')" path="content">
<n-scrollbar class="w-full max-h-[60vh]"><json-editor v-model="policyFormValue.content" /></n-scrollbar>
<n-form-item
:span="24"
v-if="policyFormValue.policy == 'custom'"
:label="t('Policy Content')"
path="content"
>
<n-scrollbar class="w-full max-h-[60vh]"
><json-editor v-model="policyFormValue.content"
/></n-scrollbar>
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitPolicyForm">{{ t("Confirm") }}</n-button>
<n-button class="mx-4" @click="showPolicyModal = false">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="submitPolicyForm">{{ t('Confirm') }}</n-button>
<n-button class="mx-4" @click="showPolicyModal = false">{{ t('Cancel') }}</n-button>
</n-form-item>
</n-form>
</n-modal>
<!-- tag -->
<n-modal v-model:show="showTagModal" :title="t('Set Tag')" preset="card" draggable :style="{ width: '550px' }">
<n-modal
v-model:show="showTagModal"
:title="t('Set Tag')"
preset="card"
draggable
:style="{ width: '550px' }"
>
<n-form ref="formRef" inline :label-width="80" :model="tagFormValue">
<n-form-item :label="t('Tag Key')" path="name">
<n-input v-model:value="tagFormValue.name" placeholder="输入标签key" />
<n-input v-model:value="tagFormValue.name" :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('Please enter tag value')" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitTagForm">{{ t("Confirm") }}</n-button>
<n-button class="mx-4" @click="showTagModal = false">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="submitTagForm">{{ t('Confirm') }}</n-button>
<n-button class="mx-4" @click="showTagModal = false">{{ t('Cancel') }}</n-button>
</n-form-item>
</n-form>
</n-modal>
@@ -154,21 +173,34 @@
:title="t('Enable Storage Encryption')"
preset="card"
draggable
:style="{ width: '550px' }">
<n-form ref="encryptFormRef" label-placemen="left" label-width="auto" inline :model="encryptFormValue">
:style="{ width: '550px' }"
>
<n-form
ref="encryptFormRef"
label-placemen="left"
label-width="auto"
inline
:model="encryptFormValue"
>
<n-form-item :label="t('Encryption Type')" path="encrypt" class="flex-auto">
<n-select
v-model:value="encryptFormValue.encrypt"
:placeholder="t('Please select encryption type')"
:options="encryptOptions" />
:options="encryptOptions"
/>
</n-form-item>
<n-form-item v-if="encryptFormValue.encrypt == 'SSE-KMS'" label="KMS Key ID" path="kmsKeyId" class="flex-auto">
<n-form-item
v-if="encryptFormValue.encrypt == 'SSE-KMS'"
label="KMS Key ID"
path="kmsKeyId"
class="flex-auto"
>
<n-select v-model:value="encryptFormValue.kmsKeyId" placeholder="" :options="[]" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitEncryptForm">{{ t("Confirm") }}</n-button>
<n-button class="mx-4" @click="showEncryptModal = false">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="submitEncryptForm">{{ t('Confirm') }}</n-button>
<n-button class="mx-4" @click="showEncryptModal = false">{{ t('Cancel') }}</n-button>
</n-form-item>
</n-form>
</n-modal>
@@ -179,19 +211,25 @@
:title="t('Set Retention')"
preset="card"
draggable
:style="{ width: '550px' }">
<n-form ref="retentionFormRef" label-placemen="left" label-width="auto" :model="retentionFormValue">
:style="{ width: '550px' }"
>
<n-form
ref="retentionFormRef"
label-placemen="left"
label-width="auto"
:model="retentionFormValue"
>
<n-form-item :label="t('Retention Mode')" path="retentionMode" class="flex-auto">
<n-radio-group v-model:value="retentionFormValue.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 Unit')" path="retentionUnit" class="flex-auto">
<n-radio-group v-model:value="retentionFormValue.retentionUnit">
<n-radio value="Days">{{ t("DAYS") }}</n-radio>
<n-radio value="Years">{{ t("YEARS") }}</n-radio>
<n-radio value="Days">{{ t('DAYS') }}</n-radio>
<n-radio value="Years">{{ t('YEARS') }}</n-radio>
</n-radio-group>
</n-form-item>
@@ -200,8 +238,8 @@
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submitRetentionForm">{{ t("Confirm") }}</n-button>
<n-button class="mx-4" @click="showRetentionModal = false">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="submitRetentionForm">{{ t('Confirm') }}</n-button>
<n-button class="mx-4" @click="showRetentionModal = false">{{ t('Cancel') }}</n-button>
</n-form-item>
</n-form>
</n-modal>
@@ -209,11 +247,11 @@
</template>
<script setup lang="ts">
import { Icon } from "#components";
import { Icon } from '#components';
const { t } = useI18n();
const dialog = useDialog();
const visibel = ref(false);
const bucketName = ref("");
const bucketName = ref('');
const openDrawer = (bucket: string) => {
visibel.value = true;
bucketName.value = bucket;
@@ -256,21 +294,24 @@ const retentionEnabled = ref(false);
const getObjectLockConfig = async () => {
objectLockLoading.value = true;
getObjectLockConfiguration(bucketName.value)
.then((res) => {
.then(res => {
if (res.ObjectLockConfiguration?.ObjectLockEnabled) {
lockStatus.value = res.ObjectLockConfiguration?.ObjectLockEnabled == "Enabled" ? true : false;
lockStatus.value =
res.ObjectLockConfiguration?.ObjectLockEnabled == 'Enabled' ? true : false;
if (res.ObjectLockConfiguration?.Rule) {
retentionEnabled.value = true;
retentionFormValue.value.retentionMode = res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Mode || null;
retentionFormValue.value.retentionMode =
res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Mode || null;
retentionFormValue.value.retentionPeriod =
res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Days ||
res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Years ||
null;
retentionFormValue.value.retentionUnit = res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Years
? "Years"
retentionFormValue.value.retentionUnit = res.ObjectLockConfiguration?.Rule
?.DefaultRetention?.Years
? 'Years'
: res.ObjectLockConfiguration?.Rule?.DefaultRetention?.Days
? "Days"
: "";
? 'Days'
: '';
}
} else {
lockStatus.value = false;
@@ -288,23 +329,27 @@ const getObjectLockConfig = async () => {
/**********object lock ***********************/
/******** policy ***********************/
import { setBucketPolicy, getBucketPolicy as getBucketPolicyFn } from "~/utils/bucket-policy";
import { setBucketPolicy, getBucketPolicy as getBucketPolicyFn } from '~/utils/bucket-policy';
const bucketPolicy = ref("public");
const bucketPolicy = ref('public');
const getbucketPolicy = async () => {
try {
const res = await getBucketPolicy(bucketName.value);
if (res.Policy) {
policyFormValue.value.content = res.Policy;
bucketPolicy.value = getBucketPolicyFn(JSON.parse(res.Policy).Statement, bucketName.value, "");
bucketPolicy.value = getBucketPolicyFn(
JSON.parse(res.Policy).Statement,
bucketName.value,
''
);
policyFormValue.value.policy = bucketPolicy.value;
if (bucketPolicy.value == "none") {
bucketPolicy.value = "custom";
policyFormValue.value.policy = "custom";
if (bucketPolicy.value == 'none') {
bucketPolicy.value = 'custom';
policyFormValue.value.policy = 'custom';
}
} else {
bucketPolicy.value = "public";
bucketPolicy.value = 'public';
}
} catch (error) {
// console.error("Error fetching bucket policy:", error)
@@ -312,8 +357,8 @@ const getbucketPolicy = async () => {
};
const policyFormValue = ref({
policy: "private",
content: "{}",
policy: 'private',
content: '{}',
});
const showPolicyModal = ref(false);
const editPolicy = () => {
@@ -321,44 +366,49 @@ const editPolicy = () => {
};
const submitPolicyForm = () => {
if (policyFormValue.value.policy == "custom") {
if (policyFormValue.value.policy == 'custom') {
let polictStr = JSON.stringify(JSON.parse(policyFormValue.value.content));
console.log(polictStr);
putBucketPolicy(bucketName.value, polictStr)
.then(() => {
message.success("修改成功");
message.success(t('Edit Success'));
showPolicyModal.value = false;
getbucketPolicy();
})
.catch((error) => {
message.error("修改失败: " + error.message);
.catch(error => {
message.error(t('Edit Failed') + ': ' + error.message);
});
} else {
const policys = setBucketPolicy([], policyFormValue.value.policy as BucketPolicyType, bucketName.value, "");
console.log("🚀 ~ policys:", policys);
putBucketPolicy(bucketName.value, JSON.stringify({ Version: "2012-10-17", Statement: policys }))
const policys = setBucketPolicy(
[],
policyFormValue.value.policy as BucketPolicyType,
bucketName.value,
''
);
console.log('🚀 ~ policys:', policys);
putBucketPolicy(bucketName.value, JSON.stringify({ Version: '2012-10-17', Statement: policys }))
.then(() => {
message.success("修改成功");
message.success(t('Edit Success'));
showPolicyModal.value = false;
getbucketPolicy();
})
.catch((error) => {
message.error("修改失败: " + error.message);
.catch(error => {
message.error(t('Edit Failed') + ': ' + error.message);
});
}
};
const policyOptions = [
{
label: t("Public"),
value: "public",
label: t('Public'),
value: 'public',
},
{
label: t("Private"),
value: "private",
label: t('Private'),
value: 'private',
},
{
label: t("Custom"),
value: "custom",
label: t('Custom'),
value: 'custom',
},
];
@@ -367,22 +417,22 @@ const policyOptions = [
/********Encrypt ***********************/
const showEncryptModal = ref(false);
const encryptFormValue = ref({
encrypt: "disabled",
kmsKeyId: "",
encrypt: 'disabled',
kmsKeyId: '',
});
const encryptOptions = [
{
label: t("Disabled"),
value: "disabled",
label: t('Disabled'),
value: 'disabled',
},
{
label: "SSE-KMS",
value: "SSE-KMS",
label: 'SSE-KMS',
value: 'SSE-KMS',
},
{
label: "SSE-S3",
value: "SSE-S3",
label: 'SSE-S3',
value: 'SSE-S3',
},
];
@@ -417,12 +467,16 @@ const submitEncryptForm = () => {
// showEncryptModal.value = false;
// });
// }
if (encryptFormValue.value.encrypt == "SSE-KMS") {
message.error("您提供的 XML 格式不正确,或者未根据我们发布的架构进行验证。 (MasterKeyID 未找到 aws:kms)。");
} else if (encryptFormValue.value.encrypt == "SSE-S3") {
message.error("指定了服务器端加密,但S3未配置。");
if (encryptFormValue.value.encrypt == 'SSE-KMS') {
message.error(
t(
'The XML format you provided is incorrect, or has not been validated against our published schema. (MasterKeyID not found aws:kms).'
)
);
} else if (encryptFormValue.value.encrypt == 'SSE-S3') {
message.error(t('Server-side encryption is specified, but S3 is not configured.'));
} else {
message.success("修改成功");
message.success(t('Edit Success'));
showEncryptModal.value = false;
}
};
@@ -430,7 +484,7 @@ const submitEncryptForm = () => {
/********Encrypt ***********************/
/********versioning ***********************/
const versioningStatus: any = ref("");
const versioningStatus: any = ref('');
const statusLoading = ref(false);
// 获取版本控制状态
const getVersioningStatus = async () => {
@@ -438,7 +492,7 @@ const getVersioningStatus = async () => {
const resp = await getBucketVersioning(bucketName.value);
versioningStatus.value = resp.Status;
} catch (error) {
console.error("获取版本控制状态失败:", error);
console.error('get version fail:', error);
}
};
@@ -446,12 +500,12 @@ const handleChangeVersionStatus = async (value: string) => {
statusLoading.value = true;
putBucketVersioning(bucketName.value, value)
.then(() => {
message.success("修改成功");
message.success(t('Edit Success'));
getVersioningStatus();
})
.finally(() => {
statusLoading.value = false;
versioningStatus.value = versioningStatus.value == "Suspended" ? "Enabled" : "Suspended";
versioningStatus.value = versioningStatus.value == 'Suspended' ? 'Enabled' : 'Suspended';
});
};
@@ -466,8 +520,8 @@ interface Tag {
const showTagModal = ref(false);
const tagFormValue = ref({
name: "",
value: "",
name: '',
value: '',
});
// 获取标签
const tags = ref<Tag[]>([]);
@@ -478,13 +532,13 @@ const getTags = async () => {
const addTag = () => {
nowTagIndex.value = -1;
tagFormValue.value = { name: "", value: "" }; // 清空表单
tagFormValue.value = { name: '', value: '' }; // 清空表单
showTagModal.value = true;
};
const submitTagForm = () => {
if (!tagFormValue.value.name || !tagFormValue.value.value) {
message.error("请填写完整的标签信息");
message.error(t('Please fill in complete tag information'));
return;
}
@@ -492,16 +546,19 @@ const submitTagForm = () => {
tags.value.push({ Key: tagFormValue.value.name, Value: tagFormValue.value.value });
}
if (nowTagIndex.value !== -1) {
tags.value[nowTagIndex.value] = { Key: tagFormValue.value.name, Value: tagFormValue.value.value };
tags.value[nowTagIndex.value] = {
Key: tagFormValue.value.name,
Value: tagFormValue.value.value,
};
}
// 调用 putBucketTagging 接口
putBucketTagging(bucketName.value, { TagSet: tags.value })
.then(() => {
showTagModal.value = false; // 关闭模态框
message.success("标签更新成功");
message.success(t('Tag Update Success'));
})
.catch((error) => {
message.error("标签更新失败: " + error.message);
.catch(error => {
message.error(t('Tag Update Failed') + ': ' + error.message);
});
};
@@ -515,10 +572,10 @@ const editTag = (index: number) => {
};
const handledeleteTag = (index: number) => {
dialog.error({
title: "警告",
content: "你确定要删除这个标签吗?",
positiveText: "确定",
negativeText: "取消",
title: t('Warning'),
content: t('Delete Tag Confirm'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
nowTagIndex.value = index;
tags.value.splice(index, 1); // 从标签列表中删除
@@ -526,10 +583,10 @@ const handledeleteTag = (index: number) => {
// 调用 putBucketTagging 接口
putBucketTagging(bucketName.value, { TagSet: tags.value })
.then(() => {
message.success("标签更新成功");
message.success(t('Tag Update Success'));
})
.catch((error) => {
message.error("删除标签失败: " + error.message);
.catch(error => {
message.error(t('Tag Delete Failed') + ': ' + error.message);
});
},
});
@@ -552,7 +609,7 @@ const retentionFormValue = ref<RetentionFormValue>({
const editRetention = () => {
if (!lockStatus.value) {
message.error("对象锁定未启用,无法设置保留");
message.error(t('Object lock is not enabled, cannot set retention'));
return;
}
showRetentionModal.value = true;
@@ -565,21 +622,27 @@ const submitRetentionForm = () => {
retentionFormValue.value.retentionPeriod == null ||
retentionFormValue.value.retentionUnit == null
) {
message.error("请填写完整的保留信息");
message.error(t('Please fill in complete retention information'));
return;
}
putObjectLockConfiguration(bucketName.value, {
ObjectLockEnabled: "Enabled",
ObjectLockEnabled: 'Enabled',
Rule: {
DefaultRetention: {
Mode: retentionFormValue.value.retentionMode,
Days: retentionFormValue.value.retentionUnit == "Days" ? retentionFormValue.value.retentionPeriod : null,
Years: retentionFormValue.value.retentionUnit == "Years" ? retentionFormValue.value.retentionPeriod : null,
Days:
retentionFormValue.value.retentionUnit == 'Days'
? retentionFormValue.value.retentionPeriod
: null,
Years:
retentionFormValue.value.retentionUnit == 'Years'
? retentionFormValue.value.retentionPeriod
: null,
},
},
}).then(() => {
message.success("修改成功");
message.success(t('Edit Success'));
showRetentionModal.value = false;
getObjectLockConfig();
});
+94 -78
View File
@@ -8,10 +8,16 @@
:segmented="{
content: true,
action: true,
}">
}"
>
<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-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" />
@@ -23,8 +29,9 @@
v-model:value="formData.days"
:min="1"
:placeholder="t('Days')"
style="width: 100px" />
<span class="ms-4">{{ t("Days After") }}</span>
style="width: 100px"
/>
<span class="ms-4">{{ t('Days After') }}</span>
</div>
</n-form-item>
<!-- 生命周期 -->
@@ -32,14 +39,18 @@
<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-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')" />
:value-placeholder="t('Tag Value')"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
@@ -47,11 +58,13 @@
<!-- 高级设置 -->
<n-card class="my-4" v-if="formData.versionType == 'current'">
<n-collapse>
<n-collapse-item title="高级设置" name="advanced">
<n-form-item label="删除标记处理">
<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">如果没有留下任何版本请删除对该对象的引用</span>
<span class="ml-4 text-gray-500">{{
t('If no versions remain, delete references to this object')
}}</span>
</n-space>
</n-form-item>
@@ -76,8 +89,9 @@
v-model:value="formData.days"
:min="1"
:placeholder="t('Days')"
style="width: 100px" />
<span class="ms-4">{{ t("Days After") }}</span>
style="width: 100px"
/>
<span class="ms-4">{{ t('Days After') }}</span>
</div>
</n-form-item>
<n-form-item :label="t('Stroage Type')" path="storageType">
@@ -89,14 +103,18 @@
<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-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')" />
:value-placeholder="t('Tag Value')"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
@@ -106,8 +124,8 @@
</n-form>
<n-space justify="center">
<n-button @click="handleCancel">{{ t("Cancel") }}</n-button>
<n-button type="primary" @click="handleSave">{{ t("Save") }}</n-button>
<n-button @click="handleCancel">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="handleSave">{{ t('Save') }}</n-button>
</n-space>
</n-card>
</n-modal>
@@ -124,22 +142,23 @@ import {
NInput,
NInputNumber,
NSelect,
} from "naive-ui";
} from 'naive-ui';
// 使用 Web Crypto API 或 Node.js crypto 模块
const randomUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return 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({});
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();
@@ -149,29 +168,29 @@ interface TierItem {
value: string;
}
const emit = defineEmits(["search"]);
const emit = defineEmits(['search']);
const formRef = ref();
const formData = ref({
ruleName: "",
ruleName: '',
type: null,
versionType: "current",
versionType: 'current',
days: null,
action: "expire",
prefix: "",
action: 'expire',
prefix: '',
expiredDeleteMark: false,
deleteAllExpired: false,
storageType: "",
storageType: '',
tags: [
{
key: "",
value: "",
key: '',
value: '',
},
],
});
const versionOptions = ref([
{ label: t("Current Version"), value: "current" },
{ label: t("Non-current Version"), value: "non-current" },
{ label: t('Current Version'), value: 'current' },
{ label: t('Non-current Version'), value: 'non-current' },
]);
// 表单验证规则
@@ -180,21 +199,21 @@ const rules = {
required: true,
validator: (rule: any, value: any) => {
if (value === null || value === undefined || value < 1) {
return new Error(t("Please enter valid days"));
return new Error(t('Please enter valid days'));
}
return true;
},
trigger: ["blur", "input", "change"],
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"));
if (formData.value.action === 'transition' && (!value || value === '')) {
return new Error(t('Please select storage type'));
}
return true;
},
trigger: ["blur", "change"],
trigger: ['blur', 'change'],
},
};
@@ -212,7 +231,7 @@ const props = defineProps({
watch(
() => props.bucketName,
(newVal) => {
newVal => {
// 获取是都开启版本管理
getVersioningStatus();
}
@@ -228,10 +247,10 @@ defineExpose({
});
const handleUpdateValue = (value: string) => {
if (value === "expire") {
formData.value.action = "expire";
if (value === 'expire') {
formData.value.action = 'expire';
} else {
formData.value.action = "transition";
formData.value.action = 'transition';
}
};
@@ -239,32 +258,36 @@ 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"));
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 === "")
formData.value.action === 'transition' &&
(!formData.value.storageType || formData.value.storageType === '')
) {
message.error(t("Please select storage type"));
message.error(t('Please select storage type'));
return;
}
// 先获取当前的生命周期配置
getBucketLifecycleConfiguration(props.bucketName)
.then((currentConfig) => {
.then(currentConfig => {
// 创建新的规则
const newRule: any = {
ID: randomUUID(),
Status: "Enabled",
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);
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);
@@ -300,8 +323,8 @@ const handleSave = () => {
}
// 根据操作类型添加相应的配置
if (formData.value.action === "expire") {
if (formData.value.versionType === "non-current") {
if (formData.value.action === 'expire') {
if (formData.value.versionType === 'non-current') {
newRule.NoncurrentVersionExpiration = {
NoncurrentDays: formData.value.days,
};
@@ -322,7 +345,7 @@ const handleSave = () => {
}
} else {
// transition 操作
if (formData.value.versionType === "non-current") {
if (formData.value.versionType === 'non-current') {
newRule.NoncurrentVersionTransitions = [
{
NoncurrentDays: formData.value.days,
@@ -339,12 +362,6 @@ const handleSave = () => {
}
}
console.log("formData.value.prefix:", formData.value.prefix);
console.log("formData.value.tags:", formData.value.tags);
console.log("formData.value.expiredDeleteMark:", formData.value.expiredDeleteMark);
console.log("Final newRule:", JSON.stringify(newRule, null, 2));
// return;
// 合并现有规则和新规则
const existingRules = currentConfig.Rules || [];
const updatedRules = [...existingRules, newRule];
@@ -353,33 +370,32 @@ const handleSave = () => {
const params = {
Rules: updatedRules,
};
console.log("🚀 ~ .then ~ params:", params);
return putBucketLifecycleConfiguration(props.bucketName, params);
})
.then((res) => {
.then(res => {
visible.value = false;
emit("search");
message.success(t("Create Success"));
emit('search');
message.success(t('Create Success'));
formData.value = {
ruleName: "",
ruleName: '',
type: null,
versionType: "current",
versionType: 'current',
days: null,
action: "expire",
action: 'expire',
expiredDeleteMark: false,
deleteAllExpired: false,
storageType: "",
prefix: "",
storageType: '',
prefix: '',
tags: [
{
key: "",
value: "",
key: '',
value: '',
},
],
};
})
.catch((e) => {
.catch(e => {
message.error(e.message);
});
}
@@ -409,9 +425,9 @@ const versioningStatus: any = ref(false);
const getVersioningStatus = async () => {
try {
const resp = await getBucketVersioning(props.bucketName);
versioningStatus.value = resp.Status == "Enabled";
versioningStatus.value = resp.Status == 'Enabled';
} catch (error) {
console.error("获取版本控制状态失败:", error);
console.error('获取版本控制状态失败:', error);
}
};
+79 -61
View File
@@ -15,25 +15,25 @@
<object-delete-stats />
<n-button @click="() => handleNewObject(true)">
<Icon name="ri:add-line" class="mr-2" />
<span>{{ t("New Folder") }}</span>
<span>{{ t('New Folder') }}</span>
</n-button>
<n-button @click="() => handleNewObject(false)">
<Icon name="ri:add-line" class="mr-2" />
<span>{{ t("New File") }}</span>
<span>{{ t('New File') }}</span>
</n-button>
<n-button @click="() => (uploadPickerVisible = true)">
<Icon name="ri:file-add-line" class="mr-2" />
<span>{{ t("Upload File") + "/" + t("Folder") }}</span>
<span>{{ t('Upload File') + '/' + t('Folder') }}</span>
</n-button>
<n-button :disabled="!checkedKeys.length" secondary @click="handleBatchDelete">
<template #icon>
<Icon name="ri:delete-bin-5-line"></Icon>
</template>
{{ t("Delete Selected") }}
{{ t('Delete Selected') }}
</n-button>
<n-button @click="() => refresh()">
<Icon name="ri:refresh-line" class="mr-2" />
<span>{{ t("Refresh") }}</span>
<span>{{ t('Refresh') }}</span>
</n-button>
</div>
</template>
@@ -45,35 +45,38 @@
:row-key="rowKey"
@update:checked-row-keys="handleCheck"
:pagination="false"
:bordered="false" />
:bordered="false"
/>
<object-upload-picker
:show="uploadPickerVisible"
@update:show="
(val) => {
val => {
uploadPickerVisible = val;
refresh();
}
"
:bucketName="bucketName"
:prefix="prefix" />
:prefix="prefix"
/>
<object-new-form
:show="newObjectFormVisible"
:asPrefix="newObjectAsPrefix"
@update:show="
(val) => {
val => {
newObjectFormVisible = val;
refresh();
}
"
:bucketName="bucketName"
:prefix="prefix" />
:prefix="prefix"
/>
<n-button-group class="ml-auto">
<n-button @click="goToPreviousPage" :disabled="!continuationToken">
<Icon name="ri:arrow-left-s-line" class="mr-2" />
<span>{{ t("Previous Page") }}</span>
<span>{{ t('Previous Page') }}</span>
</n-button>
<n-button @click="goToNextPage" :disabled="!nextToken">
<span>{{ t("Next Page") }}</span>
<span>{{ t('Next Page') }}</span>
<Icon name="ri:arrow-right-s-line" class="ml-2" />
</n-button>
</n-button-group>
@@ -83,16 +86,16 @@
<script setup lang="ts">
const { $s3Client } = useNuxtApp();
const { t } = useI18n();
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 { useBucket } from "~/composables/useBucket";
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 { useBucket } from '~/composables/useBucket';
const route = useRoute();
const router = useRouter();
@@ -103,7 +106,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) => {
@@ -162,15 +165,19 @@ const handleNewObject = (asPrefix: boolean) => {
const bucketPath = (path?: string | Array<string>) => {
if (Array.isArray(path)) {
path = path.join("/");
path = path.join('/');
}
return joinRelativeURL("/browser", encodeURIComponent(bucketName.value), path ? encodeURIComponent(path) : "");
return joinRelativeURL(
'/browser',
encodeURIComponent(bucketName.value),
path ? encodeURIComponent(path) : ''
);
};
interface RowData {
Key: string;
type: "prefix" | "object";
type: 'prefix' | 'object';
Size: number;
LastModified: string;
}
@@ -178,27 +185,30 @@ interface RowData {
const infoRef = ref();
const columns: DataTableColumns<RowData> = [
{
type: "selection",
type: 'selection',
},
{
key: "Key",
title: t("Object"),
render: (row: { Key: string; type: "prefix" | "object" }) => {
key: 'Key',
title: t('Object'),
render: (row: { Key: string; type: 'prefix' | 'object' }) => {
const displayKey = prefix.value ? row.Key.substring(prefix.value.length) : row.Key;
let label: string | VNode = displayKey || "/";
let label: string | VNode = displayKey || '/';
if (row.type === "prefix") {
label = h("span", { class: "inline-flex items-center gap-2" }, [icon("ri:folder-line"), label]);
if (row.type === 'prefix') {
label = h('span', { class: 'inline-flex items-center gap-2' }, [
icon('ri:folder-line'),
label,
]);
}
const keyInUri = row.Key;
return h(
NuxtLink,
{
href: row.type === "prefix" ? bucketPath(keyInUri) : "",
class: "block text-cyan-400 cursor-pointer",
href: row.type === 'prefix' ? bucketPath(keyInUri) : '',
class: 'block text-cyan-400 cursor-pointer',
onClick: (e: MouseEvent) => {
if (row.type === "prefix") return;
if (row.type === 'prefix') return;
infoRef.value.openDrawer(bucketName.value, row.Key);
return;
},
@@ -207,12 +217,16 @@ const columns: DataTableColumns<RowData> = [
);
},
},
{ key: "Size", title: t("Size"), render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : "") },
{
key: "LastModified",
title: t("Update Time"),
key: 'Size',
title: t('Size'),
render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : ''),
},
{
key: 'LastModified',
title: t('Update Time'),
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') : '';
},
},
];
@@ -224,8 +238,8 @@ 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));
}
@@ -239,7 +253,7 @@ const { data, refresh } = await useAsyncData<ListObjectsResponse>(
const params = {
Bucket: bucketName.value,
MaxKeys: pageSize.value || 25,
Delimiter: "/",
Delimiter: '/',
Prefix: prefix.value || undefined,
ContinuationToken: continuationToken.value,
};
@@ -269,18 +283,18 @@ const nextToken = computed(() => data.value?.nextContinuationToken || null);
const objects = computed(() => {
return commonPrefixes.value
.map((prefix) => {
.map(prefix => {
return {
Key: prefix.Prefix,
type: "prefix",
type: 'prefix',
Size: 0,
};
})
.concat(
contents.value.map((object) => {
contents.value.map(object => {
return {
Key: object.Key,
type: "object",
type: 'object',
Size: object.Size ?? 0,
LastModified: object.LastModified ?? new Date(0),
};
@@ -295,7 +309,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);
});
@@ -317,36 +331,40 @@ const bucketApi = useBucket({});
// 批量删除
function handleBatchDelete() {
dialog.error({
title: "警告",
content: "你确定要删除所有选中的对象吗?",
positiveText: "确定",
negativeText: "取消",
title: t('Warning'),
content: t('Are you sure you want to delete all selected objects?'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
if (!checkedKeys.value.length) {
message.error("请至少选择一项");
message.error(t('Please select at least one item'));
return;
}
try {
await Promise.all(
checkedKeys.value.map(async (item) => {
const findOne = objects.value.find((obj) => obj.Key === item);
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);
});
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);
})
);
message.success("删除任务已创建");
message.success(t('Delete task created'));
salt.value = randomString();
refresh();
} catch (error) {
message.error("删除失败");
message.error(t('Delete Failed'));
}
},
});
+57 -31
View File
@@ -2,24 +2,42 @@
<n-button text v-if="total > 0" @click="toggleDrawer">
<div v-if="pending.length" class="flex items-center gap-2">
<n-spin :size="14" />
<span>{{ t('In Progress', { total: total, processing: processing.length, completed: completed.length }) }}</span>
<span>{{
t('In Progress', {
total: total,
processing: processing.length,
completed: completed.length,
})
}}</span>
</div>
<div v-else>
{{ t('Task Completed', { completed: completed.length, failed: failed.length }) }}
</div>
<div v-else>{{ t('Task Completed', { completed: completed.length, failed: failed.length }) }}</div>
</n-button>
<n-drawer v-model:show="showDrawer" :width="502">
<n-drawer-content :title="t('Task Management')" closable>
<div class="flex flex-col gap-4">
<n-alert type="warning" :show-icon="false">
<p><span class="text-red-500">{{ t('Browser Warning') }}</span></p>
<p><span class="text-red-500">{{ t('Cache Warning') }}</span></p>
<p>
<span class="text-red-500">{{ t('Browser Warning') }}</span>
</p>
<p>
<span class="text-red-500">{{ t('Cache Warning') }}</span>
</p>
</n-alert>
<div v-if="total > 0">
<div class="flex items-center justify-between">
<div>{{ total - (pending.length + processing.length) }}/{{ total }}</div>
<n-button text type="info" @click="clearTasks">{{ t('Clear Records') }}</n-button>
</div>
<n-progress type="line" :height="2" :percentage="percentage" :show-indicator="false" :processing="percentage < 100" />
<n-progress
type="line"
:height="2"
:percentage="percentage"
:show-indicator="false"
:processing="percentage < 100"
/>
</div>
<n-tabs type="line" animated v-model:value="tab">
<n-tab-pane :tab="t('Pending', { count: pending.length })" name="pending">
@@ -34,6 +52,9 @@
<n-tab-pane :tab="t('Failed', { count: failed.length })" name="failed">
<slot name="task-list" :tasks="failed" />
</n-tab-pane>
<n-tab-pane :tab="t('Paused', { count: paused.length })" name="paused">
<slot name="task-list" :tasks="paused" />
</n-tab-pane>
</n-tabs>
</div>
</n-drawer-content>
@@ -41,51 +62,56 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
tasks: any[]
processingStatus: string
onClearTasks: () => void
}>()
tasks: any[];
processingStatus: string;
onClearTasks: () => void;
}>();
const { t } = useI18n()
const showDrawer = ref(false)
const { t } = useI18n();
const showDrawer = ref(false);
const total = computed(() => props.tasks.length)
const percentage = computed(() => total.value === 0 ? 100 : Math.floor((completed.value.length / total.value) * 100))
const total = computed(() => props.tasks.length);
const percentage = computed(() =>
total.value === 0 ? 100 : Math.floor((completed.value.length / total.value) * 100)
);
const pending = computed(() => props.tasks.filter(task => task.status === 'pending'))
const processing = computed(() => props.tasks.filter(task => task.status === props.processingStatus))
const completed = computed(() => props.tasks.filter(task => task.status === 'completed'))
const failed = computed(() => props.tasks.filter(task => task.status === 'failed'))
const pending = computed(() => props.tasks.filter(task => task.status === 'pending'));
const processing = computed(() =>
props.tasks.filter(task => task.status === props.processingStatus)
);
const completed = computed(() => props.tasks.filter(task => task.status === 'completed'));
const failed = computed(() => props.tasks.filter(task => task.status === 'failed'));
const paused = computed(() => props.tasks.filter(task => task.status === 'paused'));
const tab = ref('pending')
const tab = ref('pending');
// 根据任务执行状态切换任务列表
watch(percentage, () => {
if (processing.value.length > 0) {
tab.value = 'processing'
tab.value = 'processing';
} else {
tab.value = 'completed'
tab.value = 'completed';
}
})
});
// 第一次加入任务时自动打开抽屉
const autoOpened = ref(false)
const autoOpened = ref(false);
watch(total, (newVal, oldVal) => {
if (!autoOpened.value && !showDrawer.value && oldVal === 0 && newVal > 0) {
showDrawer.value = true
autoOpened.value = true
showDrawer.value = true;
autoOpened.value = true;
}
})
});
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
showDrawer.value = !showDrawer.value;
};
const clearTasks = () => {
props.onClearTasks()
}
props.onClearTasks();
};
</script>
+91 -56
View File
@@ -2,7 +2,7 @@
<n-modal :show="show" @update:show="(val: boolean) => $emit('update:show', val)" size="huge">
<n-card class="max-w-screen-md">
<template #header>
<div style="display:flex; justify-content: space-between; align-items:center;">
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ t('Upload File') }}</span>
<n-button size="small" ghost @click="closeModal">{{ t('Close') }}</n-button>
</div>
@@ -10,7 +10,14 @@
<div class="flex flex-col gap-4">
<div class="flex items-center gap-4">
<input type="file" ref="fileInput" multiple hidden @change="handleFileSelect" />
<input type="file" ref="folderInput" webkitdirectory directory hidden @change="handleFolderSelect" />
<input
type="file"
ref="folderInput"
webkitdirectory
directory
hidden
@change="handleFolderSelect"
/>
<n-button type="primary" @click="selectFile">{{ t('Select File') }}</n-button>
<div>{{ t('Or') }}</div>
<n-button @click="selectFolder">{{ t('Select Folder') }}</n-button>
@@ -23,16 +30,24 @@
</n-alert>
<div @dragover.prevent @drop.prevent="handleDrop">
<div v-if="selectedItems.length === 0" class="flex flex-col gap-6 items-center justify-center border border-dashed border-muted rounded p-4 h-[40vh]">
<div
v-if="selectedItems.length === 0"
class="flex flex-col gap-6 items-center justify-center border border-dashed border-muted rounded p-4 h-[40vh]"
>
<div class="text-gray-500 font-bold">{{ t('No Selection') }}</div>
<div class="text-muted-foreground text-center">
{{ t('Drag Drop Info') }}<br>
{{ t('Drag Drop Info') }}<br />
{{ t('File Size Limit') }}
</div>
</div>
<!-- 虚拟滚动文件与文件夹列表 -->
<n-virtual-list v-if="selectedItems.length" :items="selectedItems" :item-size="40" class="h-[40vh] overflow-y-auto border rounded">
<n-virtual-list
v-if="selectedItems.length"
:items="selectedItems"
:item-size="40"
class="h-[40vh] overflow-y-auto border rounded"
>
<template #default="{ item, index }">
<div class="flex items-center gap-2 w-full border-b py-1">
<div class="flex-1">
@@ -48,15 +63,25 @@
</n-virtual-list>
</div>
<n-progress v-if="isAdding" :percentage="addProgress" type="line" :show-indicator="true" style="margin-bottom: 16px;">
<template #default>
{{ t('Adding to Upload Queue') }} ({{ addProgress }}%)
</template>
<n-progress
v-if="isAdding"
:percentage="addProgress"
type="line"
:show-indicator="true"
style="margin-bottom: 16px"
>
<template #default> {{ t('Adding to Upload Queue') }} ({{ addProgress }}%) </template>
</n-progress>
<div class="flex justify-center gap-4">
<n-button type="default" :disabled="!hasFiles || isAdding">{{ t('Configure') }}</n-button>
<n-button type="primary" :disabled="!hasFiles || isAdding" :loading="isAdding" @click="handleUpload">{{ t('Start Upload') }}</n-button>
<n-button
type="primary"
:disabled="!hasFiles || isAdding"
:loading="isAdding"
@click="handleUpload"
>{{ t('Start Upload') }}</n-button
>
</div>
</div>
</n-card>
@@ -64,98 +89,108 @@
</template>
<script setup lang="ts">
import { computed, defineEmits, defineProps, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
import { computed, defineEmits, defineProps, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUploadTaskManagerStore } from '~/store/upload-tasks';
const { t } = useI18n()
const uploadTaskManagerStore = useUploadTaskManagerStore()
const { t } = useI18n();
const uploadTaskManagerStore = useUploadTaskManagerStore();
interface FileItem {
file: File,
prefix: string
file: File;
prefix: string;
}
interface SelectedItem {
type: 'file' | 'folder'
name: string
size: number
files?: FileItem[]
type: 'file' | 'folder';
name: string;
size: number;
files?: FileItem[];
}
const emit = defineEmits(['update:show', 'submit'])
const emit = defineEmits(['update:show', 'submit']);
const props = defineProps<{ show: boolean; bucketName: string; prefix: string }>()
const props = defineProps<{ show: boolean; bucketName: string; prefix: string }>();
const fileInput = ref<HTMLInputElement | null>(null)
const folderInput = ref<HTMLInputElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null);
const folderInput = ref<HTMLInputElement | null>(null);
// 待上传的文件/文件夹列表
const selectedItems = ref<SelectedItem[]>([])
const selectedItems = ref<SelectedItem[]>([]);
const isAdding = ref(false)
const addProgress = ref(0)
const isAdding = ref(false);
const addProgress = ref(0);
const closeModal = () => emit('update:show', false)
const selectFile = () => fileInput.value?.click()
const selectFolder = () => folderInput.value?.click()
const closeModal = () => emit('update:show', false);
const selectFile = () => fileInput.value?.click();
const selectFolder = () => folderInput.value?.click();
const handleFileSelect = (e: Event) => {
const input = e.target as HTMLInputElement
const input = e.target as HTMLInputElement;
if (input.files) {
// 更新展示列表
Array.from(input.files).forEach(f => selectedItems.value.push(
{ type: 'file', name: f.name, size: f.size, files: [{ file: f, prefix: props.prefix }] }
))
Array.from(input.files).forEach(f =>
selectedItems.value.push({
type: 'file',
name: f.name,
size: f.size,
files: [{ file: f, prefix: props.prefix }],
})
);
}
}
};
const handleFolderSelect = (e: Event) => {
const input = e.target as HTMLInputElement
const input = e.target as HTMLInputElement;
if (input.files) {
const folderFiles = Array.from(input.files)
const folderMap = new Map<string, File[]>()
const folderFiles = Array.from(input.files);
const folderMap = new Map<string, File[]>();
for (const file of folderFiles) {
// 保留完整目录路径(去掉最后的文件名)
const pathParts = file.webkitRelativePath.split('/')
pathParts.pop() // 移除文件名
const folderPath = pathParts.join('/') || t('Unknown Folder')
const pathParts = file.webkitRelativePath.split('/');
pathParts.pop(); // 移除文件名
const folderPath = pathParts.join('/') || t('Unknown Folder');
if (!folderMap.has(folderPath)) {
folderMap.set(folderPath, [])
folderMap.set(folderPath, []);
}
folderMap.get(folderPath)?.push(file)
folderMap.get(folderPath)?.push(file);
}
// 将每个完整路径和对应文件放到 selectedItems
for (const [folderPath, files] of folderMap.entries()) {
const totalSize = files.reduce((acc, file) => acc + file.size, 0)
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
selectedItems.value.push({
type: 'folder',
name: folderPath,
size: totalSize,
files: files.map(file => ({ file, prefix: `${props.prefix}${folderPath}/` }))
})
files: files.map(file => ({ file, prefix: `${props.prefix}${folderPath}/` })),
});
}
}
}
};
const handleDrop = (e: DragEvent) => {
if (e.dataTransfer?.files) {
const droppedFiles = Array.from(e.dataTransfer.files)
droppedFiles.forEach(file => selectedItems.value.push({
type: 'file', name: file.name, size: file.size, files: [{ file, prefix: props.prefix }]
}))
const droppedFiles = Array.from(e.dataTransfer.files);
droppedFiles.forEach(file =>
selectedItems.value.push({
type: 'file',
name: file.name,
size: file.size,
files: [{ file, prefix: props.prefix }],
})
);
}
}
};
const hasFiles = computed(() => selectedItems.value.length > 0)
const allFiles = computed(() => selectedItems.value.flatMap(item => item.files || []))
const hasFiles = computed(() => selectedItems.value.length > 0);
const allFiles = computed(() => selectedItems.value.flatMap(item => item.files || []));
function removeItem(index: number) {
selectedItems.value.splice(index, 1)
selectedItems.value.splice(index, 1);
}
async function handleUpload() {
+30 -9
View File
@@ -2,9 +2,23 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<div>{{ task.file.name }}</div>
<n-button text @click="handleDeleteTask" type="info">{{ t('Delete Record') }}</n-button>
<div class="flex gap-2">
<n-button v-if="task.status === 'uploading'" text @click="handlePauseTask" type="warning">{{
t('Pause')
}}</n-button>
<n-button v-if="task.status === 'paused'" text @click="handleResumeTask" type="success">{{
t('Resume')
}}</n-button>
<n-button text @click="handleDeleteTask" type="info">{{ t('Delete Record') }}</n-button>
</div>
</div>
<n-progress type="line" :height="2" :percentage="task.progress" :show-indicator="false" :processing="task.status == 'uploading'" />
<n-progress
type="line"
:height="2"
:percentage="task.progress"
:show-indicator="false"
:processing="task.status == 'uploading'"
/>
<div class="flex items-center justify-between text-muted-foreground">
<div>{{ formatBytes(task.file.size) }}</div>
<div class="text-muted-foreground">
@@ -12,20 +26,27 @@
<span v-else-if="task.status === 'uploading'">{{ t('Uploading Status') }}</span>
<span v-else-if="task.status === 'completed'">{{ t('Success Status') }}</span>
<span v-else-if="task.status === 'failed'">{{ t('Failed Status') }}</span>
<span v-else-if="task.status === 'paused'">{{ t('Paused') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { UploadTask } from '~/lib/upload-task-manager'
import { useUploadTaskManagerStore } from '~/store/upload-tasks'
const { t } = useI18n()
const store = useUploadTaskManagerStore()
import type { UploadTask } from '~/lib/upload-task-manager';
import { useUploadTaskManagerStore } from '~/store/upload-tasks';
const { t } = useI18n();
const store = useUploadTaskManagerStore();
const props = defineProps<{ task: UploadTask }>()
const props = defineProps<{ task: UploadTask }>();
const handleDeleteTask = () => {
store.removeTask(props.task.id)
}
store.removeTask(props.task.id);
};
const handlePauseTask = () => {
store.pauseTask(props.task.id);
};
const handleResumeTask = () => {
store.resumeTask(props.task.id);
};
</script>
+6 -4
View File
@@ -1,12 +1,14 @@
<template>
<div class="flex flex-col gap-3">
<div v-if="tasks.length <= 0" class="mx-auto text-muted-foreground text-center">{{ t('No Tasks') }}</div>
<div v-if="tasks.length <= 0" class="mx-auto text-muted-foreground text-center">
{{ t('No Tasks') }}
</div>
<object-upload-task-item v-for="task in tasks" :key="task.id" :task="task" />
</div>
</template>
<script setup lang="ts">
import type { UploadTask } from '~/lib/upload-task-manager'
const { t } = useI18n()
defineProps<{ tasks: UploadTask[] }>()
import type { UploadTask } from '~/lib/upload-task-manager';
const { t } = useI18n();
defineProps<{ tasks: UploadTask[] }>();
</script>
+46 -44
View File
@@ -4,7 +4,7 @@
<n-form ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between" v-if="!editStatus">
<n-form-item class="!w-64" label="" path="name">
<n-input placeholder="搜索用户" @input="filterName" />
<n-input :placeholder="t('Search User')" @input="filterName" />
</n-form-item>
<n-space>
@@ -13,18 +13,18 @@
<template #icon>
<Icon name="ri:add-line"></Icon>
</template>
编辑成员
{{ t('Edit User') }}
</NButton>
</NFlex>
</n-space>
</n-flex>
<n-flex justify="space-between" v-else>
<n-form-item class="!w-96" label="选择用户组的成员" path="members">
<n-form-item class="!w-96" :label="t('Select user group members')" path="members">
<n-select v-model:value="members" filterable multiple :options="users" />
</n-form-item>
<n-space>
<NFlex>
<NButton secondary @click="changeMebers">提交</NButton>
<NButton secondary @click="changeMebers">{{ t('Submit') }}</NButton>
</NFlex>
</n-space>
</n-flex>
@@ -36,91 +36,93 @@
:columns="columns"
:data="listData"
:pagination="false"
:bordered="false" />
:bordered="false"
/>
</div>
</template>
<script setup lang="ts">
import { type DataTableColumns, type DataTableInst, NButton, NSpace } from "naive-ui"
const { listUsers } = useUsers()
const { updateGroupMembers } = useGroups()
import { type DataTableColumns, type DataTableInst, NButton, NSpace } from 'naive-ui';
const { listUsers } = useUsers();
const { updateGroupMembers } = useGroups();
const { t } = useI18n();
const messge = useMessage()
const messge = useMessage();
const props = defineProps({
group: {
type: Object,
required: true,
},
})
});
const searchForm = reactive({
name: "",
})
name: '',
});
interface RowData {
name: string
name: string;
}
const columns: DataTableColumns<RowData> = [
{
title: "名称",
align: "left",
key: "name",
title: '名称',
align: 'left',
key: 'name',
filter(value, row) {
return !!row.name.includes(value.toString())
return !!row.name.includes(value.toString());
},
},
]
];
// 搜索过滤
const tableRef = ref<DataTableInst>()
const tableRef = ref<DataTableInst>();
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
name: [value],
})
});
}
const listData = computed(() => {
return (
props.group.members.map((item: string) => {
return {
name: item,
}
};
}) || []
)
})
);
});
/********************编辑****************/
const editStatus = ref(false)
const editStatus = ref(false);
// 用户列表
const users = ref<any[]>([])
const users = ref<any[]>([]);
const emit = defineEmits<{
(e: "search"): void
}>()
(e: 'search'): void;
}>();
const getUserList = async () => {
const res = await listUsers()
const res = await listUsers();
users.value = Object.entries(res).map(([username, info]) => ({
label: username,
value: username,
...(typeof info === "object" ? info : {}), // 展开用户信息
}))
}
getUserList()
...(typeof info === 'object' ? info : {}), // 展开用户信息
}));
};
getUserList();
const members = ref([...props.group.members])
const members = ref([...props.group.members]);
const changeMebers = async () => {
try {
// 删除不存在的
const nowRemoveMembers = props.group.members.filter((item: string) => {
return !members.value.includes(item)
})
return !members.value.includes(item);
});
if (nowRemoveMembers.length) {
await updateGroupMembers({
group: props.group.name,
members: nowRemoveMembers,
isRemove: true,
groupStatus: "enabled",
})
groupStatus: 'enabled',
});
}
// 修改组的成员
@@ -128,16 +130,16 @@ const changeMebers = async () => {
group: props.group.name,
members: members.value,
isRemove: false,
groupStatus: "enabled",
})
groupStatus: 'enabled',
});
messge.success("修改成功")
editStatus.value = false
emit("search")
messge.success('修改成功');
editStatus.value = false;
emit('search');
} catch {
messge.error("修改失败")
messge.error('修改失败');
}
}
};
</script>
<style lang="scss" scoped></style>
+44 -37
View File
@@ -1,65 +1,71 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
interface Props {
visible: boolean
visible: boolean;
}
const { visible } = defineProps<Props>()
const message = useMessage()
const { $api } = useNuxtApp()
const group = useGroups()
const user = useUsers()
const { visible } = defineProps<Props>();
const message = useMessage();
const { $api } = useNuxtApp();
const group = useGroups();
const user = useUsers();
const emit = defineEmits<Emits>()
const emit = defineEmits<Emits>();
const defaultFormModal = {
group: "",
group: '',
members: [],
}
const formModel = ref({ ...defaultFormModal })
};
const formModel = ref({ ...defaultFormModal });
interface Emits {
(e: "update:visible", visible: boolean): void
(e: "search"): void
(e: 'update:visible', visible: boolean): void;
(e: 'search'): void;
}
// 用户列表
const users = ref<any[]>([])
const users = ref<any[]>([]);
const getUserList = async () => {
const res = await user.listUsers()
const res = await user.listUsers();
users.value = Object.entries(res).map(([username, info]) => ({
label: username, // 添加用户名
value: username, // 添加用户名
}))
}
getUserList()
}));
};
getUserList();
const modalVisible = computed({
get() {
return visible
return visible;
},
set(visible) {
closeModal(visible)
closeModal(visible);
},
})
});
function closeModal(visible = false) {
formModel.value = { ...defaultFormModal }
emit("update:visible", visible)
formModel.value = { ...defaultFormModal };
emit('update:visible', visible);
}
async function submitForm() {
if (formModel.value.group.trim() === "") {
message.error("请输入用户组名称")
return
if (formModel.value.group.trim() === '') {
message.error(t('Please enter user group name'));
return;
}
try {
const res = await group.updateGroupMembers({ ...formModel.value, groupStatus: "enabled", isRemove: false })
const res = await group.updateGroupMembers({
...formModel.value,
groupStatus: 'enabled',
isRemove: false,
});
message.success("添加成功")
closeModal()
emit("search")
message.success(t('Add success'));
closeModal();
emit('search');
} catch (error) {
message.error("添加失败")
message.error(t('Add failed'));
}
}
</script>
@@ -69,19 +75,20 @@ async function submitForm() {
v-model:show="modalVisible"
:mask-closable="false"
preset="card"
title="添加用户组"
:title="t('Add group members')"
class="max-w-screen-md"
:segmented="{
content: true,
action: true,
}">
}"
>
<n-card>
<n-form label-placement="left" :model="formModel" label-align="right" :label-width="130">
<n-grid :cols="24" :x-gap="18">
<n-form-item-grid-item :span="24" label="名称" path="group" required>
<n-form-item-grid-item :span="24" :label="t('Name')" path="group" required>
<n-input v-model:value="formModel.group" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="24" label="用户" path="members">
<n-form-item-grid-item :span="24" :label="t('Users')" path="members">
<n-select v-model:value="formModel.members" filterable multiple :options="users" />
</n-form-item-grid-item>
</n-grid>
@@ -89,8 +96,8 @@ async function submitForm() {
</n-card>
<template #action>
<n-space justify="center">
<n-button @click="closeModal()">取消</n-button>
<n-button type="primary" @click="submitForm">提交</n-button>
<n-button @click="closeModal()">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="submitForm">{{ t('Submit') }}</n-button>
</n-space>
</template>
</n-modal>
+53 -62
View File
@@ -1,14 +1,10 @@
<template>
<div>
<n-card>
<n-form
ref="formRef"
:model="searchForm"
label-placement="left"
:show-feedback="false">
<n-form ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between" v-if="!editStatus">
<n-form-item class="!w-64" label="" path="name">
<n-input placeholder="搜索策略" @input="filterName" />
<n-input :placeholder="t('Search Policy')" @input="filterName" />
</n-form-item>
<n-space>
@@ -17,22 +13,18 @@
<template #icon>
<Icon name="ri:add-line"></Icon>
</template>
编辑策略
{{ t('Edit Policy') }}
</NButton>
</NFlex>
</n-space>
</n-flex>
<n-flex justify="space-between" v-else>
<n-form-item class="!w-96" label="选择用户组的策略" path="policies">
<n-select
v-model:value="name"
filterable
multiple
:options="polices" />
<n-form-item class="!w-96" :label="t('Select user group policies')" path="policies">
<n-select v-model:value="name" filterable multiple :options="polices" />
</n-form-item>
<n-space>
<NFlex>
<NButton secondary @click="changePolicies">提交</NButton>
<NButton secondary @click="changePolicies">{{ t('Submit') }}</NButton>
</NFlex>
</n-space>
</n-flex>
@@ -44,32 +36,29 @@
:columns="columns"
:data="listData"
:pagination="false"
:bordered="false" />
:bordered="false"
/>
</div>
</template>
<script setup lang="ts">
import {
type DataTableColumns,
type DataTableInst,
NButton,
NSpace
} from 'naive-ui'
const { listPolicies, setUserOrGroupPolicy } = usePolicies()
import { type DataTableColumns, type DataTableInst, NButton, NSpace } from 'naive-ui';
const { listPolicies, setUserOrGroupPolicy } = usePolicies();
const { t } = useI18n();
const messge = useMessage()
const messge = useMessage();
const props = defineProps({
group: {
type: Object,
required: true
}
})
required: true,
},
});
const searchForm = reactive({
name: ''
})
name: '',
});
interface RowData {
name: string
name: string;
}
const columns: DataTableColumns<RowData> = [
@@ -78,66 +67,68 @@ const columns: DataTableColumns<RowData> = [
align: 'left',
key: 'name',
filter(value, row) {
return !!row.name.includes(value.toString())
}
}
]
return !!row.name.includes(value.toString());
},
},
];
// 搜索过滤
const tableRef = ref<DataTableInst>()
const tableRef = ref<DataTableInst>();
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
name: [value]
})
name: [value],
});
}
const listData = computed(() => {
if (!props.group?.policy) return []
if (!props.group?.policy) return [];
return (
props.group?.policy?.split(',').map((item: string) => {
return {
name: item
}
name: item,
};
}) || []
)
})
);
});
/********************编辑****************/
const editStatus = ref(false)
const editStatus = ref(false);
// 策略列表
const polices = ref<any[]>([])
const polices = ref<any[]>([]);
const emit = defineEmits<{
(e: 'search'): void
}>()
(e: 'search'): void;
}>();
const getPoliciesList = async () => {
const res = await listPolicies()
polices.value = Object.keys(res).sort((a,b)=>a.localeCompare(b)).map((key) => {
return {
label: key,
value: key
}
})
}
getPoliciesList()
const res = await listPolicies();
polices.value = Object.keys(res)
.sort((a, b) => a.localeCompare(b))
.map(key => {
return {
label: key,
value: key,
};
});
};
getPoliciesList();
// const name = ref(props.group?.policy?.split(',') || [])
const name = ref(props.group?.policy ? props.group?.policy?.split(',') : [])
const name = ref(props.group?.policy ? props.group?.policy?.split(',') : []);
const changePolicies = async () => {
try {
await setUserOrGroupPolicy({
policyName: name.value || '',
userOrGroup: encodeURIComponent(props.group.name),
isGroup: true
})
isGroup: true,
});
messge.success('修改成功')
editStatus.value = false
emit('search')
messge.success('修改成功');
editStatus.value = false;
emit('search');
} catch {
messge.error('修改失败')
messge.error('修改失败');
}
}
};
</script>
<style lang="scss" scoped></style>
+66 -55
View File
@@ -4,22 +4,23 @@
v-model:show="visible"
:mask-closable="false"
preset="card"
title="批量分配策略"
:title="t('Batch allocation policies')"
class="w-1/2"
:segmented="{
content: true,
action: true,
}">
}"
>
<n-card>
<n-form ref="formRef" :model="searchForm" label-placement="left" :show-feedback="false">
<n-flex justify="space-between">
<n-form-item class="!w-64" label="" path="name">
<n-input placeholder="搜索策略" @input="filterName" />
<n-input :placeholder="t('Search Policy')" @input="filterName" />
</n-form-item>
<n-space>
<NFlex>
<NButton secondary @click="changePolicies">提交</NButton>
<NButton secondary @click="changePolicies">{{ t('Submit') }}</NButton>
</NFlex>
</n-space>
</n-flex>
@@ -33,104 +34,114 @@
:pagination="false"
:bordered="false"
:row-key="rowKey"
@update:checked-row-keys="handleCheck" />
@update:checked-row-keys="handleCheck"
/>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { type DataTableColumns, type DataTableInst, type DataTableRowKey, NButton, NSpace } from "naive-ui"
const { listPolicies, setUserOrGroupPolicy } = usePolicies()
const {t} = useI18n()
import {
type DataTableColumns,
type DataTableInst,
type DataTableRowKey,
NButton,
NSpace,
} from 'naive-ui';
const { listPolicies, setUserOrGroupPolicy } = usePolicies();
const { t } = useI18n();
const props = defineProps({
checkedKeys: {
type: Array,
required: true,
},
})
const messge = useMessage()
});
const messge = useMessage();
const visible = ref(false)
const visible = ref(false);
async function openDialog() {
visible.value = true
visible.value = true;
}
defineExpose({
openDialog,
})
});
const searchForm = reactive({
name: "",
})
name: '',
});
interface RowData {
name: string
name: string;
}
const columns: DataTableColumns<RowData> = [
{
type: "selection",
type: 'selection',
},
{
title: "策略",
align: "left",
key: "name",
title: '策略',
align: 'left',
key: 'name',
filter(value, row) {
return !!row.name.includes(value.toString())
return !!row.name.includes(value.toString());
},
},
]
];
// 搜索过滤
const tableRef = ref<DataTableInst>()
const tableRef = ref<DataTableInst>();
function filterName(value: string) {
tableRef.value &&
tableRef.value.filter({
name: [value],
})
});
}
const listData = ref<any[]>([])
const listData = ref<any[]>([]);
const getPoliciesList = async () => {
const res = await listPolicies()
listData.value = Object.keys(res).sort((a,b)=>a.localeCompare(b)).map((key) => {
return {
name: key,
content: res[key],
}
})
}
getPoliciesList()
const res = await listPolicies();
listData.value = Object.keys(res)
.sort((a, b) => a.localeCompare(b))
.map(key => {
return {
name: key,
content: res[key],
};
});
};
getPoliciesList();
/********************编辑****************/
function rowKey(row: any): string {
return row.name
return row.name;
}
const checkedKeys = ref<DataTableRowKey[]>([])
const checkedKeys = ref<DataTableRowKey[]>([]);
function handleCheck(keys: DataTableRowKey[]) {
checkedKeys.value = keys
return checkedKeys
checkedKeys.value = keys;
return checkedKeys;
}
const emit = defineEmits(['changePoliciesSuccess'])
const emit = defineEmits(['changePoliciesSuccess']);
const changePolicies = async () => {
Promise.all(
props.checkedKeys.map((item) => {
return setUserOrGroupPolicy({
policyName: checkedKeys.value,
userOrGroup: item,
isGroup: true,
})
})
).then(()=>{
visible.value = false
messge.success(t('Edit Success'))
emit('changePoliciesSuccess')
}).catch(()=>{
messge.error(t('Edit Failed'))
Promise.all(
props.checkedKeys.map(item => {
return setUserOrGroupPolicy({
policyName: checkedKeys.value,
userOrGroup: item,
isGroup: true,
});
})
}
)
.then(() => {
visible.value = false;
messge.success(t('Edit Success'));
emit('changePoliciesSuccess');
})
.catch(() => {
messge.error(t('Edit Failed'));
});
};
</script>
<style lang="scss" scoped></style>
+14 -14
View File
@@ -366,17 +366,17 @@ async function submitForm() {
: null,
});
message.success('添加成功');
message.success(t('Add Success'));
cancelAdd();
emit('notice', res);
getUserList();
} catch (error) {
console.log(error);
message.error('添加失败');
message.error(t('Add Failed'));
}
} else {
console.log(errors);
message.error('请填写正确的格式');
message.error(t('Please fill in the correct format'));
}
});
} else {
@@ -386,11 +386,11 @@ async function submitForm() {
policy: formModel.value.policy || '{}',
expiry: new Date(formModel.value.expiry || 0).toISOString(),
});
message.success('修改成功');
message.success(t('Edit Success'));
cancelAdd();
getUserList();
} catch (error) {
message.error('修改失败');
message.error(t('Edit Failed'));
}
}
}
@@ -402,10 +402,10 @@ function dateDisabled(ts: number) {
async function deleteItem(row: any) {
try {
const res = await deleteServiceAccount(row.accessKey);
message.success('删除成功');
message.success(t('Delete Success'));
getUserList();
} catch (error) {
message.error('删除失败');
message.error(t('Delete Failed'));
}
}
@@ -421,13 +421,13 @@ function handleCheck(keys: DataTableRowKey[]) {
}
function deleteByList() {
dialog.error({
title: '警告',
content: '你确定要删除所有选中的秘钥吗?',
positiveText: '确定',
negativeText: '取消',
title: t('Warning'),
content: t('Are you sure you want to delete all selected keys?'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
if (!checkedKeys.value.length) {
message.error('请至少选择一项');
message.error(t('Please select at least one item'));
return;
}
try {
@@ -436,9 +436,9 @@ function deleteByList() {
// })
checkedKeys.value = [];
getUserList();
message.success('删除成功');
message.success(t('Delete Success'));
} catch (error) {
message.error('删除失败');
message.error(t('Delete Failed'));
}
},
});
+29 -25
View File
@@ -4,12 +4,13 @@
v-model:show="visible"
:mask-closable="false"
preset="card"
title="新用户已创建"
:title="t('New user has been created')"
class="max-w-screen-md"
:segmented="{
content: true,
action: true
}">
action: true,
}"
>
<n-card>
<n-form label-placement="left" label-align="right" :label-width="130">
<n-grid :cols="24" :x-gap="18">
@@ -18,22 +19,24 @@
class="w-full"
v-model="accessKey"
:readonly="true"
:copy-icon="true"></copy-input>
:copy-icon="true"
></copy-input>
</n-form-item-grid-item>
<n-form-item-grid-item :span="24" label="secretkey">
<copy-input
class="w-full"
v-model="secretkey"
:readonly="true"
:copy-icon="true"></copy-input>
:copy-icon="true"
></copy-input>
</n-form-item-grid-item>
</n-grid>
</n-form>
</n-card>
<template #action>
<n-space justify="center">
<n-button @click="closeModal()">取消</n-button>
<n-button type="primary" @click="exportFile">导出</n-button>
<n-button @click="closeModal()">{{ t('Cancel') }}</n-button>
<n-button type="primary" @click="exportFile">{{ t('Export') }}</n-button>
</n-space>
</template>
</n-modal>
@@ -41,29 +44,30 @@
</template>
<script setup lang="ts">
import { download } from '@/utils/export-file'
const visible = ref(false)
import { download } from '@/utils/export-file';
const visible = ref(false);
const { t } = useI18n();
const accessKey = ref('')
const secretkey = ref('')
const url = ref('')
const accessKey = ref('');
const secretkey = ref('');
const url = ref('');
function openDialog(data: any) {
accessKey.value = data.credentials.accessKey
secretkey.value = data.credentials.secretKey
url.value = data.url
visible.value = true
accessKey.value = data.credentials.accessKey;
secretkey.value = data.credentials.secretKey;
url.value = data.url;
visible.value = true;
}
const emit = defineEmits(['search'])
const emit = defineEmits(['search']);
function closeModal() {
visible.value = false
accessKey.value = ''
secretkey.value = ''
emit('search')
visible.value = false;
accessKey.value = '';
secretkey.value = '';
emit('search');
}
defineExpose({
openDialog
})
openDialog,
});
function exportFile() {
download(
@@ -73,9 +77,9 @@ function exportFile() {
accessKey: accessKey.value,
secretKey: secretkey.value,
api: 's3v4',
path: 'auto'
path: 'auto',
})
)
);
}
</script>
+3 -3
View File
@@ -24,7 +24,7 @@
</n-form-item>
<n-space>
<NFlex>
<NButton secondary @click="changeMebers">提交</NButton>
<NButton secondary @click="changeMebers">{{ t('Submit') }}</NButton>
</NFlex>
</n-space>
</n-flex>
@@ -119,11 +119,11 @@ const changeMebers = async () => {
userOrGroup: encodeURIComponent(props.user.accessKey),
isGroup: false,
});
messge.success('修改成功');
messge.success(t('Edit Success'));
editStatus.value = false;
emit('search');
} catch {
messge.error('修改失败');
messge.error(t('Edit Failed'));
}
};
</script>
View File
+14 -7
View File
@@ -165,10 +165,14 @@
"Browser Warning": "Browser Warning",
"Cache Warning": "Cache Warning",
"Clear Records": "Clear Records",
"Pending": "Pending",
"Uploading": "Uploading",
"Completed": "Completed",
"Failed": "Failed",
"Pending": "Pending({count})",
"Processing": "Processing({count})",
"Uploading": "Uploading({count})",
"Completed": "Completed({count})",
"Failed": "Failed({count})",
"Paused": "Paused({count})",
"Pause": "Pause",
"Resume": "Resume",
"Delete Record": "Delete Record",
"Waiting": "Waiting",
"Uploading Status": "Uploading Status",
@@ -402,7 +406,7 @@
"Search Account": "Search Account",
"Edit policy": "Edit policy",
"Search Policy": "Search Policy",
"Bucket Configuration":"Bucket Configuration",
"Bucket Configuration": "Bucket Configuration",
"Adding to Upload Queue": "Adding to Upload Queue",
"Adding to Delete Queue": "Adding to Delete Queue",
@@ -540,10 +544,13 @@
"Search Access Key": "Search Access Key",
"Add Access Key": "Add Access Key",
"Expiration": "Expiration",
"Processing": "Processing",
"Disabled": "Disabled",
"Description": "Description",
"Are you sure you want to delete all selected keys?": "Are you sure you want to delete all selected keys?",
"Please select at least one item": "Please select at least one item",
"Get Data Failed": "Get Data Failed"
"Get Data Failed": "Get Data Failed",
"New user has been created": "New user has been created",
"Export": "Export",
"Login expired, please login again": "Login expired, please login again",
"Batch allocation policies": "Batch allocation policies"
}
+14 -5
View File
@@ -27,8 +27,8 @@
"Name": "名称",
"Search": "搜索",
"Edit": "编辑",
"Edit Success": "编辑成功",
"Edit Failed": "编辑失败",
"Edit Success": "修改成功",
"Edit Failed": "修改失败",
"Add Success": "添加成功",
"Add Failed": "添加失败",
"Delete Failed": "删除失败",
@@ -191,8 +191,12 @@
"Clear Records": "清空记录",
"Pending": "待处理({count})",
"Uploading": "进行中({count})",
"Processing": "进行中({count})",
"Completed": "已完成({count})",
"Failed": "已失败({count})",
"Paused": "已暂停({count})",
"Pause": "暂停",
"Resume": "恢复",
"Delete Record": "删除记录",
"Waiting": "等待上传",
"Uploading Status": "上传中",
@@ -406,6 +410,8 @@
"Storage Class": "存储类型",
"Version ID": "版本ID",
"Submit Data": "提交数据",
"New user has been created": "新用户已创建",
"Export": "导出",
"Get Version Control Status Failed": "获取版本控制状态失败",
"The XML format you provided is incorrect, or has not been validated against our published schema. (MasterKeyID not found aws:kms).": "您提供的XML格式不正确,或者未根据我们发布的架构进行验证。(未找到MasterKeyID aws:kms)。",
"Server-side encryption is specified, but S3 is not configured.": "指定了服务器端加密,但S3未配置。",
@@ -512,7 +518,6 @@
"Search Access Key": "搜索访问密钥",
"Add Access Key": "添加访问密钥",
"Expiration": "过期时间",
"Processing": "进行中",
"Disabled": "已禁用",
"Description": "描述",
"Are you sure you want to delete all selected keys?": "您确定要删除所有选中的密钥吗?",
@@ -631,7 +636,11 @@
"Search Account": "搜索账户",
"Edit policy": "编辑策略",
"Search Policy": "搜索策略",
"Bucket Configuration":"桶配置",
"Bucket Configuration": "桶配置",
"Adding to Upload Queue": "添加到上传队列",
"Adding to Delete Queue": "添加到删除队列"
"Adding to Delete Queue": "添加到删除队列",
"Login expired, please login again": "登录信息已过期,请重新登录",
"Batch allocation policies": "批量分配策略",
"Add group members": "添加组成员",
"Search User Group": "搜索用户组"
}
+24 -21
View File
@@ -1,7 +1,7 @@
import { joinURL } from "ufo";
import { parseApiError } from "~/utils/error-handler";
import { logger } from "~/utils/logger";
import type { AwsClient } from "./aws4fetch";
import { joinURL } from 'ufo';
import { parseApiError } from '~/utils/error-handler';
import { logger } from '~/utils/logger';
import type { AwsClient } from './aws4fetch';
interface ApiClientOptions {
baseUrl?: string;
@@ -36,12 +36,12 @@ class ApiClient {
delete options.params; // 删除params,以免影响fetch的options
}
logger.log("[request] url:", url);
logger.log("[request] options:", options);
logger.log('[request] url:', url);
logger.log('[request] options:', options);
const response = await this.$api.fetch(url, options);
logger.log("[request] response:", response);
logger.log('[request] response:', response);
if (!response.ok) {
const errorMsg = await parseApiError(response);
@@ -51,15 +51,18 @@ class ApiClient {
// 处理401
if (response.status === 401) {
const message = useMessage();
message.error("登录信息已过期,请重新登录");
// 清除登录信息
await useAuth().logout();
window.location.href = "/auth/login";
window.location.href = '/auth/login';
return;
}
// 204 or body length is 0
if (response.status === 204 || response.headers.get("content-length") === "0" || !response.body) {
if (
response.status === 204 ||
response.headers.get('content-length') === '0' ||
!response.body
) {
return null;
}
@@ -74,11 +77,11 @@ class ApiClient {
const response = await this.request(url, options, false);
if (!response.body) {
throw new Error("No response body");
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
@@ -88,40 +91,40 @@ class ApiClient {
const data = JSON.parse(chunk);
yield data; // 使用 yield 返回数据
} catch (error) {
logger.error("Failed to parse chunk:", error);
logger.error('Failed to parse chunk:', error);
}
}
}
async get(url: string, options?: RequestOptions) {
return this.request(url, { method: "GET", ...options });
return this.request(url, { method: 'GET', ...options });
}
async post(url: string, body: any, options?: RequestOptions) {
return this.request(url, { method: "POST", body, ...options });
return this.request(url, { method: 'POST', body, ...options });
}
async delete(url: string, options?: RequestOptions) {
return this.request(url, { method: "DELETE", ...options });
return this.request(url, { method: 'DELETE', ...options });
}
async put(url: string, body: any, options?: RequestOptions) {
return this.request(url, { method: "PUT", body, ...options });
return this.request(url, { method: 'PUT', body, ...options });
}
async patch(url: string, body: any, options?: RequestOptions) {
return this.request(url, { method: "PATCH", body, ...options });
return this.request(url, { method: 'PATCH', body, ...options });
}
async head(url: string, options?: RequestOptions) {
return this.request(url, { method: "HEAD", ...options });
return this.request(url, { method: 'HEAD', ...options });
}
async options(url: string, options?: RequestOptions) {
return this.request(url, { method: "OPTIONS", ...options });
return this.request(url, { method: 'OPTIONS', ...options });
}
async trace(url: string, options?: RequestOptions) {
return this.request(url, { method: "TRACE", ...options });
return this.request(url, { method: 'TRACE', ...options });
}
}
+94 -31
View File
@@ -4,6 +4,7 @@ import {
PutObjectCommand,
S3Client,
UploadPartCommand,
ListPartsCommand,
} from '@aws-sdk/client-s3';
export interface UploadTask {
@@ -11,11 +12,14 @@ export interface UploadTask {
file: File;
bucketName: string;
prefix?: string;
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'canceled';
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'canceled' | 'paused';
progress: number;
error?: string;
abortController?: AbortController;
retryCount?: number;
uploadId?: string;
completedParts?: { ETag: string; PartNumber: number }[];
_pauseRequested?: boolean;
}
export interface TaskManagerConfig {
@@ -96,6 +100,7 @@ class UploadTaskManager {
cancelTask(taskId: string) {
const task = this.tasks.find(task => task.id === taskId);
if (task) {
task._pauseRequested = false;
if (task.abortController) {
task.abortController.abort();
}
@@ -129,6 +134,58 @@ class UploadTaskManager {
this.isStarted = false;
}
pauseTask(taskId: string) {
const task = this.tasks.find(t => t.id === taskId);
if (task && task.status === 'uploading') {
task._pauseRequested = true;
task.abortController?.abort();
}
}
async resumeTask(taskId: string) {
const task = this.tasks.find(t => t.id === taskId);
if (task && task.status === 'paused') {
task._pauseRequested = false; // 恢复前清除暂停标记
// 1. 校验 uploadId 是否有效
try {
if (task.uploadId) {
const listParts = await this.s3Client.send(
new ListPartsCommand({
Bucket: task.bucketName,
Key: (task.prefix || '') + task.file.name,
UploadId: task.uploadId,
})
);
// 2. 以 S3 返回的分片为准,更新 task.completedParts
task.completedParts = (listParts.Parts || []).map((p: any) => ({
ETag: p.ETag,
PartNumber: p.PartNumber,
}));
// 3. 开始断点续传
this.startUpload(task);
} else {
// 没有 uploadId,直接重新上传
task.completedParts = [];
this.startUpload(task);
}
} catch (e: any) {
if (
e.name === 'NoSuchUpload' ||
(typeof e.message === 'string' && e.message.includes('Invalid upload id'))
) {
// uploadId 已失效,重新发起分片上传
task.uploadId = undefined;
task.completedParts = [];
this.startUpload(task);
} else {
throw e;
}
}
}
}
// 简化的队列处理逻辑
private async processQueue() {
if (!this.isStarted) {
@@ -169,8 +226,17 @@ class UploadTaskManager {
task.status = 'completed';
task.progress = 100;
} catch (error: any) {
// 检查是否是取消操作
// 判断是否为“暂停”操作
if (
task._pauseRequested &&
(error.message === 'Upload paused' || error.message === 'Request aborted')
) {
task.status = 'paused';
return;
}
// 只有真正取消时才清理
if (error.name === 'AbortError' || error.message?.includes('canceled')) {
// 这里才调用 AbortMultipartUploadCommand
task.status = 'canceled';
return;
}
@@ -262,39 +328,38 @@ class UploadTaskManager {
task.abortController = abortController;
const Key = prefix + file.name;
let uploadId: string | undefined;
let uploadId: string | undefined = task.uploadId;
let completedParts: { ETag: string; PartNumber: number }[] = task.completedParts || [];
try {
// 创建分片上传
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: Key,
ContentType: file.type || 'application/octet-stream',
});
const createResponse = await this.s3Client.send(createCommand, {
abortSignal: abortController.signal,
});
uploadId = createResponse.UploadId;
if (!uploadId) {
// 创建分片上传
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: Key,
ContentType: file.type || 'application/octet-stream',
});
const createResponse = await this.s3Client.send(createCommand, {
abortSignal: abortController.signal,
});
uploadId = createResponse.UploadId;
task.uploadId = uploadId;
task.completedParts = [];
}
if (!uploadId) {
throw new Error('Failed to create multipart upload');
}
const totalChunks = Math.ceil(file.size / this.chunkSize);
const completedParts = [];
// 上传分片
for (let partNumber = 1; partNumber <= totalChunks; partNumber++) {
// 检查是否被取消
if (abortController.signal.aborted) {
throw new Error('Upload canceled');
throw new Error('Upload paused');
}
// 跳过已完成分片
if (completedParts.some(p => p.PartNumber === partNumber)) continue;
const start = (partNumber - 1) * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
const uploadPartCommand = new UploadPartCommand({
Bucket: bucketName,
Key: Key,
@@ -302,19 +367,16 @@ class UploadTaskManager {
PartNumber: partNumber,
Body: chunk,
});
const { ETag } = await this.s3Client.send(uploadPartCommand, {
abortSignal: abortController.signal,
});
if (!ETag) {
throw new Error(`Failed to upload part ${partNumber}`);
}
completedParts.push({ ETag, PartNumber: partNumber });
task.progress = Math.round((partNumber / totalChunks) * 100);
task.completedParts = completedParts;
task.progress = Math.round((completedParts.length / totalChunks) * 100);
}
// 完成分片上传
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: bucketName,
@@ -322,13 +384,15 @@ class UploadTaskManager {
UploadId: uploadId,
MultipartUpload: { Parts: completedParts },
});
await this.s3Client.send(completeCommand, {
abortSignal: abortController.signal,
});
// 上传完成后清理 uploadId 和 completedParts
task.uploadId = undefined;
task.completedParts = undefined;
} catch (error: any) {
// 如果上传失败,尝试清理分片上传
if (uploadId) {
// 只在“非暂停”情况下才调用 AbortMultipartUploadCommand
if (!task._pauseRequested) {
try {
const { AbortMultipartUploadCommand } = await import('@aws-sdk/client-s3');
const abortCommand = new AbortMultipartUploadCommand({
@@ -341,7 +405,6 @@ class UploadTaskManager {
console.warn('Failed to cleanup multipart upload:', cleanupError);
}
}
throw error;
}
}
+26 -14
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import UploadTaskManager from "~/lib/upload-task-manager";
import { defineStore } from 'pinia';
import UploadTaskManager from '~/lib/upload-task-manager';
export const useUploadTaskManagerStore = defineStore("uploadTaskManager", {
export const useUploadTaskManagerStore = defineStore('uploadTaskManager', {
state: () => ({
taskManager: new UploadTaskManager(useNuxtApp().$s3Client, {
chunkSize: 16, // 16MB chunks for better performance
@@ -11,15 +11,19 @@ export const useUploadTaskManagerStore = defineStore("uploadTaskManager", {
}),
}),
getters: {
tasks: (state) => state.taskManager.getTasks(),
pendingTasks: (state) => state.taskManager.getTasks().filter(task => task.status === "pending"),
uploadingTasks: (state) => state.taskManager.getTasks().filter(task => task.status === "uploading"),
completedTasks: (state) => state.taskManager.getTasks().filter(task => task.status === "completed"),
failedTasks: (state) => state.taskManager.getTasks().filter(task => task.status === "failed"),
tasks: state => state.taskManager.getTasks(),
pendingTasks: state => state.taskManager.getTasks().filter(task => task.status === 'pending'),
uploadingTasks: state =>
state.taskManager.getTasks().filter(task => task.status === 'uploading'),
completedTasks: state =>
state.taskManager.getTasks().filter(task => task.status === 'completed'),
failedTasks: state => state.taskManager.getTasks().filter(task => task.status === 'failed'),
canceledTasks: state => state.taskManager.getTasks().filter(task => task.status === 'canceled'),
pausedTasks: state => state.taskManager.getTasks().filter(task => task.status === 'paused'),
},
actions: {
addFiles(files: File[], bucketName: string, prefix?: string) {
console.log("addFiles", files.length, "files to bucket", bucketName, "prefix:", prefix);
console.log('addFiles', files.length, 'files to bucket', bucketName, 'prefix:', prefix);
// 对于大量文件,分批处理
const batchSize = 50;
@@ -54,16 +58,24 @@ export const useUploadTaskManagerStore = defineStore("uploadTaskManager", {
this.taskManager.clearTasks();
},
pauseTask(taskId: string) {
this.taskManager.pauseTask(taskId);
},
resumeTask(taskId: string) {
this.taskManager.resumeTask(taskId);
},
// 获取统计信息
getStats() {
const tasks = this.tasks;
return {
total: tasks.length,
pending: tasks.filter(task => task.status === "pending").length,
uploading: tasks.filter(task => task.status === "uploading").length,
completed: tasks.filter(task => task.status === "completed").length,
failed: tasks.filter(task => task.status === "failed").length,
canceled: tasks.filter(task => task.status === "canceled").length,
pending: tasks.filter(task => task.status === 'pending').length,
uploading: tasks.filter(task => task.status === 'uploading').length,
completed: tasks.filter(task => task.status === 'completed').length,
failed: tasks.filter(task => task.status === 'failed').length,
canceled: tasks.filter(task => task.status === 'canceled').length,
paused: tasks.filter(task => task.status === 'paused').length,
};
},
},