feat(admin): 添加 S3 兼容存储备份功能

- 新增 S3 配置管理页面,支持 AWS S3、Cloudflare R2、MinIO 等兼容存储
- 实现 S3 客户端工具类,使用 aws4fetch 进行签名和上传
- 添加手动触发备份功能,将评论、配置和统计数据打包为 JSON 上传至 S3
- 在管理后台数据管理页面集成 S3 配置表单和备份操作按钮
- 新增相关 API 端点用于获取/保存 S3 配置及触发备份
This commit is contained in:
anghunk
2026-02-12 15:26:58 +08:00
parent ccffd1df67
commit 379de95961
9 changed files with 365 additions and 1 deletions

View File

@@ -388,3 +388,23 @@ export function setupTelegramWebhook(): Promise<{ message: string; webhookUrl: s
export function sendTelegramTestMessage(): Promise<{ message: string }> {
return post<{ message: string }>('/admin/settings/telegram/test', {});
}
export type S3SettingsResponse = {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
region: string;
};
export function fetchS3Settings(): Promise<S3SettingsResponse> {
return get<S3SettingsResponse>('/admin/settings/s3');
}
export function saveS3Settings(data: S3SettingsResponse): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/s3', data);
}
export function triggerS3Backup(): Promise<{ message: string; file: string }> {
return post<{ message: string; file: string }>('/admin/backup/s3', {});
}

View File

@@ -115,6 +115,22 @@
"desc": "一键备份或恢复系统所有数据(评论 + 配置 + 统计)。",
"export": "全量导出",
"import": "全量恢复"
},
"s3": {
"title": "S3 备份",
"desc": "配置 S3 兼容存储AWS S3, Cloudflare R2, MinIO 等),并支持手动触发备份。",
"endpoint": "API 端点 (Endpoint)",
"endpointHint": "例如: https://[account_id].r2.cloudflarestorage.com",
"region": "区域 (Region)",
"regionHint": "例如: auto, us-east-1",
"bucket": "存储桶 (Bucket)",
"accessKey": "Access Key ID",
"secretKey": "Secret Access Key",
"save": "保存配置",
"saving": "保存中...",
"backup": "立即备份到 S3",
"backingUp": "正在备份...",
"success": "备份成功!文件: {file}"
}
},
"logs": {

View File

@@ -109,6 +109,62 @@
</div>
</div>
<!-- 5. S3 备份 -->
<div class="card">
<h3 class="card-title">{{ t("data.sections.s3.title") }}</h3>
<p class="card-desc">{{ t("data.sections.s3.desc") }}</p>
<div class="form-group">
<label class="form-label">{{ t("data.sections.s3.endpoint") }}</label>
<input
v-model="s3Config.endpoint"
class="form-input"
:placeholder="t('data.sections.s3.endpointHint')"
/>
</div>
<div class="form-row">
<div class="form-group half">
<label class="form-label">{{ t("data.sections.s3.bucket") }}</label>
<input v-model="s3Config.bucket" class="form-input" />
</div>
<div class="form-group half">
<label class="form-label">{{ t("data.sections.s3.region") }}</label>
<input
v-model="s3Config.region"
class="form-input"
:placeholder="t('data.sections.s3.regionHint')"
/>
</div>
</div>
<div class="form-row">
<div class="form-group half">
<label class="form-label">{{ t("data.sections.s3.accessKey") }}</label>
<input v-model="s3Config.accessKeyId" class="form-input" />
</div>
<div class="form-group half">
<label class="form-label">{{ t("data.sections.s3.secretKey") }}</label>
<input v-model="s3Config.secretAccessKey" class="form-input" />
</div>
</div>
<div class="action-row" style="margin-top: 16px">
<button class="card-button primary" :disabled="s3Saving" @click="handleSaveS3">
{{ s3Saving ? t("data.sections.s3.saving") : t("data.sections.s3.save") }}
</button>
<button
class="card-button secondary"
:disabled="s3BackingUp"
@click="handleS3Backup"
>
{{
s3BackingUp ? t("data.sections.s3.backingUp") : t("data.sections.s3.backup")
}}
</button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
type="file"
@@ -161,7 +217,7 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import {
exportComments,
@@ -172,6 +228,10 @@ import {
importStats,
exportBackup,
importBackup,
fetchS3Settings,
saveS3Settings,
triggerS3Backup,
type S3SettingsResponse,
} from "../../api/admin";
import { useSite } from "../../composables/useSite";
@@ -195,6 +255,52 @@ const showPrefixModal = ref(false);
const urlPrefix = ref("");
const missingPrefixCount = ref(0);
const pendingJson = ref<any[]>([]);
const s3Config = ref<S3SettingsResponse>({
endpoint: "",
accessKeyId: "",
secretAccessKey: "",
bucket: "",
region: "auto",
});
const s3Saving = ref(false);
const s3BackingUp = ref(false);
async function loadS3Config() {
try {
const res = await fetchS3Settings();
s3Config.value = res;
} catch (e) {
// silent fail or log
}
}
async function handleSaveS3() {
s3Saving.value = true;
try {
await saveS3Settings(s3Config.value);
showToast(t("common.saveSuccess"));
} catch (e: any) {
showToast(e.message || t("common.saveFailed"), "error");
} finally {
s3Saving.value = false;
}
}
async function handleS3Backup() {
s3BackingUp.value = true;
try {
const res = await triggerS3Backup();
showToast(t("data.sections.s3.success", { file: res.file }));
} catch (e: any) {
showToast(e.message || t("data.messages.exportFailed"), "error");
} finally {
s3BackingUp.value = false;
}
}
onMounted(() => {
loadS3Config();
});
function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg;
@@ -424,4 +530,26 @@ function joinUrl(prefix: string, path: string): string {
<style scoped lang="less">
@import "../../styles/components/data.less";
.form-row {
display: flex;
gap: 12px;
margin-top: 12px;
}
.form-group {
margin-top: 12px;
}
.form-group.half {
flex: 1;
margin-top: 0;
}
.form-label {
display: block;
font-size: 13px;
margin-bottom: 4px;
color: var(--text-primary);
}
</style>