Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
aa4e37d0
Unverified
Commit
aa4e37d0
authored
Mar 14, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 14, 2026
Browse files
Merge pull request #966 from GuangYiDing/feat/db-backup-restore
feat: 数据库定时备份与恢复(S3 兼容存储,支持 Cloudflare R2)
parents
a1dc0089
1047f973
Changes
22
Show whitespace changes
Inline
Side-by-side
frontend/src/router/index.ts
View file @
aa4e37d0
...
...
@@ -350,6 +350,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
admin.promo.description
'
}
},
{
path
:
'
/admin/backup
'
,
name
:
'
AdminBackup
'
,
component
:
()
=>
import
(
'
@/views/admin/BackupView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Database Backup
'
,
titleKey
:
'
admin.backup.title
'
,
descriptionKey
:
'
admin.backup.description
'
}
},
{
path
:
'
/admin/data-management
'
,
name
:
'
AdminDataManagement
'
,
...
...
frontend/src/views/admin/BackupView.vue
0 → 100644
View file @
aa4e37d0
<
template
>
<AppLayout>
<div
class=
"space-y-6"
>
<!-- S3 Storage Config -->
<div
class=
"card p-6"
>
<div
class=
"mb-4 flex flex-wrap items-center justify-between gap-3"
>
<div>
<h3
class=
"text-base font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.backup.s3.title
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.descriptionPrefix
'
)
}}
<button
type=
"button"
class=
"text-primary-600 underline hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@
click=
"showR2Guide = true"
>
Cloudflare R2
</button>
{{
t
(
'
admin.backup.s3.descriptionSuffix
'
)
}}
</p>
</div>
</div>
<div
class=
"grid grid-cols-1 gap-3 md:grid-cols-2"
>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.endpoint
'
)
}}
</label>
<input
v-model=
"s3Form.endpoint"
class=
"input w-full"
placeholder=
"https://
<account_id>
.r2.cloudflarestorage.com" />
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.region
'
)
}}
</label>
<input
v-model=
"s3Form.region"
class=
"input w-full"
placeholder=
"auto"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.bucket
'
)
}}
</label>
<input
v-model=
"s3Form.bucket"
class=
"input w-full"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.prefix
'
)
}}
</label>
<input
v-model=
"s3Form.prefix"
class=
"input w-full"
placeholder=
"backups/"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.accessKeyId
'
)
}}
</label>
<input
v-model=
"s3Form.access_key_id"
class=
"input w-full"
/>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.s3.secretAccessKey
'
)
}}
</label>
<input
v-model=
"s3Form.secret_access_key"
type=
"password"
class=
"input w-full"
:placeholder=
"s3SecretConfigured ? t('admin.backup.s3.secretConfigured') : ''"
/>
</div>
<label
class=
"inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2"
>
<input
v-model=
"s3Form.force_path_style"
type=
"checkbox"
/>
<span>
{{
t
(
'
admin.backup.s3.forcePathStyle
'
)
}}
</span>
</label>
</div>
<div
class=
"mt-4 flex flex-wrap gap-2"
>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"testingS3"
@
click=
"testS3"
>
{{
testingS3
?
t
(
'
common.loading
'
)
:
t
(
'
admin.backup.s3.testConnection
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"savingS3"
@
click=
"saveS3Config"
>
{{
savingS3
?
t
(
'
common.loading
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</div>
<!-- Schedule Config -->
<div
class=
"card p-6"
>
<div
class=
"mb-4"
>
<h3
class=
"text-base font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.backup.schedule.title
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.description
'
)
}}
</p>
</div>
<div
class=
"grid grid-cols-1 gap-3 md:grid-cols-2"
>
<label
class=
"inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2"
>
<input
v-model=
"scheduleForm.enabled"
type=
"checkbox"
/>
<span>
{{
t
(
'
admin.backup.schedule.enabled
'
)
}}
</span>
</label>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.cronExpr
'
)
}}
</label>
<input
v-model=
"scheduleForm.cron_expr"
class=
"input w-full"
placeholder=
"0 2 * * *"
/>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.cronHint
'
)
}}
</p>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.retainDays
'
)
}}
</label>
<input
v-model.number=
"scheduleForm.retain_days"
type=
"number"
min=
"0"
class=
"input w-full"
/>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.retainDaysHint
'
)
}}
</p>
</div>
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.retainCount
'
)
}}
</label>
<input
v-model.number=
"scheduleForm.retain_count"
type=
"number"
min=
"0"
class=
"input w-full"
/>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.schedule.retainCountHint
'
)
}}
</p>
</div>
</div>
<div
class=
"mt-4"
>
<button
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"savingSchedule"
@
click=
"saveSchedule"
>
{{
savingSchedule
?
t
(
'
common.loading
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</div>
<!-- Backup Operations -->
<div
class=
"card p-6"
>
<div
class=
"mb-4 flex flex-wrap items-center justify-between gap-3"
>
<div>
<h3
class=
"text-base font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.backup.operations.title
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.backup.operations.description
'
)
}}
</p>
</div>
<div
class=
"flex flex-wrap items-center gap-2"
>
<div
class=
"flex items-center gap-1"
>
<label
class=
"text-xs text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.backup.operations.expireDays
'
)
}}
</label>
<input
v-model.number=
"manualExpireDays"
type=
"number"
min=
"0"
class=
"input w-20 text-xs"
/>
</div>
<button
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"creatingBackup"
@
click=
"createBackup"
>
{{
creatingBackup
?
t
(
'
admin.backup.operations.backing
'
)
:
t
(
'
admin.backup.operations.createBackup
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"loadingBackups"
@
click=
"loadBackups"
>
{{
loadingBackups
?
t
(
'
common.loading
'
)
:
t
(
'
common.refresh
'
)
}}
</button>
</div>
</div>
<div
class=
"overflow-x-auto"
>
<table
class=
"w-full min-w-[800px] text-sm"
>
<thead>
<tr
class=
"border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
<th
class=
"py-2 pr-4"
>
ID
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.status
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.fileName
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.size
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.expiresAt
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.triggeredBy
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.backup.columns.startedAt
'
)
}}
</th>
<th
class=
"py-2"
>
{{
t
(
'
admin.backup.columns.actions
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"record in backups"
:key=
"record.id"
class=
"border-b border-gray-100 align-top dark:border-dark-800"
>
<td
class=
"py-3 pr-4 font-mono text-xs"
>
{{
record
.
id
}}
</td>
<td
class=
"py-3 pr-4"
>
<span
class=
"rounded px-2 py-0.5 text-xs"
:class=
"statusClass(record.status)"
>
{{
t
(
`admin.backup.status.${record.status
}
`
)
}}
<
/span
>
<
/td
>
<
td
class
=
"
py-3 pr-4 text-xs
"
>
{{
record
.
file_name
}}
<
/td
>
<
td
class
=
"
py-3 pr-4 text-xs
"
>
{{
formatSize
(
record
.
size_bytes
)
}}
<
/td
>
<
td
class
=
"
py-3 pr-4 text-xs
"
>
{{
record
.
expires_at
?
formatDate
(
record
.
expires_at
)
:
t
(
'
admin.backup.neverExpire
'
)
}}
<
/td
>
<
td
class
=
"
py-3 pr-4 text-xs
"
>
{{
record
.
triggered_by
===
'
scheduled
'
?
t
(
'
admin.backup.trigger.scheduled
'
)
:
t
(
'
admin.backup.trigger.manual
'
)
}}
<
/td
>
<
td
class
=
"
py-3 pr-4 text-xs
"
>
{{
formatDate
(
record
.
started_at
)
}}
<
/td
>
<
td
class
=
"
py-3 text-xs
"
>
<
div
class
=
"
flex flex-wrap gap-1
"
>
<
button
v
-
if
=
"
record.status === 'completed'
"
type
=
"
button
"
class
=
"
btn btn-secondary btn-xs
"
@
click
=
"
downloadBackup(record.id)
"
>
{{
t
(
'
admin.backup.actions.download
'
)
}}
<
/button
>
<
button
v
-
if
=
"
record.status === 'completed'
"
type
=
"
button
"
class
=
"
btn btn-secondary btn-xs
"
:
disabled
=
"
restoringId === record.id
"
@
click
=
"
restoreBackup(record.id)
"
>
{{
restoringId
===
record
.
id
?
t
(
'
common.loading
'
)
:
t
(
'
admin.backup.actions.restore
'
)
}}
<
/button
>
<
button
type
=
"
button
"
class
=
"
btn btn-danger btn-xs
"
@
click
=
"
removeBackup(record.id)
"
>
{{
t
(
'
common.delete
'
)
}}
<
/button
>
<
/div
>
<
/td
>
<
/tr
>
<
tr
v
-
if
=
"
backups.length === 0
"
>
<
td
colspan
=
"
8
"
class
=
"
py-6 text-center text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.backup.empty
'
)
}}
<
/td
>
<
/tr
>
<
/tbody
>
<
/table
>
<
/div
>
<
/div
>
<
/div
>
<!--
Cloudflare
R2
Setup
Guide
Modal
-->
<
teleport
to
=
"
body
"
>
<
transition
name
=
"
modal
"
>
<
div
v
-
if
=
"
showR2Guide
"
class
=
"
fixed inset-0 z-50 flex items-center justify-center p-4
"
@
mousedown
.
self
=
"
showR2Guide = false
"
>
<
div
class
=
"
fixed inset-0 bg-black/50
"
@
click
=
"
showR2Guide = false
"
><
/div
>
<
div
class
=
"
relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800
"
>
<
button
type
=
"
button
"
class
=
"
absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200
"
@
click
=
"
showR2Guide = false
"
>
<
svg
class
=
"
h-5 w-5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M6 18L18 6M6 6l12 12
"
/><
/svg
>
<
/button
>
<
h2
class
=
"
mb-4 text-lg font-bold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.backup.r2Guide.title
'
)
}}
<
/h2
>
<
p
class
=
"
mb-4 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.backup.r2Guide.intro
'
)
}}
<
/p
>
<!--
Step
1
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
1
<
/span
>
{{
t
(
'
admin.backup.r2Guide.step1.title
'
)
}}
<
/h3
>
<
ol
class
=
"
ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300
"
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step1.line1
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step1.line2
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step1.line3
'
)
}}
<
/li
>
<
/ol
>
<
/div
>
<!--
Step
2
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
2
<
/span
>
{{
t
(
'
admin.backup.r2Guide.step2.title
'
)
}}
<
/h3
>
<
ol
class
=
"
ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300
"
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step2.line1
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step2.line2
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step2.line3
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.backup.r2Guide.step2.line4
'
)
}}
<
/li
>
<
/ol
>
<
div
class
=
"
mt-2 rounded-lg bg-amber-50 p-3 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300
"
>
{{
t
(
'
admin.backup.r2Guide.step2.warning
'
)
}}
<
/div
>
<
/div
>
<!--
Step
3
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
3
<
/span
>
{{
t
(
'
admin.backup.r2Guide.step3.title
'
)
}}
<
/h3
>
<
p
class
=
"
ml-8 text-sm text-gray-600 dark:text-gray-300
"
>
{{
t
(
'
admin.backup.r2Guide.step3.desc
'
)
}}
<
/p
>
<
code
class
=
"
ml-8 mt-1 block rounded bg-gray-100 px-3 py-2 text-xs text-gray-800 dark:bg-dark-700 dark:text-gray-200
"
>
https
:
//<
{{
t
(
'
admin.backup.r2Guide.step3.accountId
'
)
}}
&
gt
;.
r2
.
cloudflarestorage
.
com
<
/code
>
<
/div
>
<!--
Step
4
:
Fill
form
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
4
<
/span
>
{{
t
(
'
admin.backup.r2Guide.step4.title
'
)
}}
<
/h3
>
<
div
class
=
"
ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600
"
>
<
table
class
=
"
w-full text-sm
"
>
<
tbody
>
<
tr
v
-
for
=
"
(row, i) in r2ConfigRows
"
:
key
=
"
i
"
class
=
"
border-b border-gray-100 dark:border-dark-700 last:border-0
"
>
<
td
class
=
"
whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300
"
>
{{
row
.
field
}}
<
/td
>
<
td
class
=
"
px-3 py-2 text-gray-600 dark:text-gray-400
"
><
code
class
=
"
text-xs
"
>
{{
row
.
value
}}
<
/code></
td
>
<
/tr
>
<
/tbody
>
<
/table
>
<
/div
>
<
/div
>
<!--
Free
tier
note
-->
<
div
class
=
"
rounded-lg bg-green-50 p-3 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-300
"
>
{{
t
(
'
admin.backup.r2Guide.freeTier
'
)
}}
<
/div
>
<
div
class
=
"
mt-4 text-right
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-primary btn-sm
"
@
click
=
"
showR2Guide = false
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/transition
>
<
/teleport
>
<
/AppLayout
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
{
adminAPI
}
from
'
@/api
'
import
{
useAppStore
}
from
'
@/stores
'
import
type
{
BackupS3Config
,
BackupScheduleConfig
,
BackupRecord
}
from
'
@/api/admin/backup
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
// S3 config
const
s3Form
=
ref
<
BackupS3Config
>
({
endpoint
:
''
,
region
:
'
auto
'
,
bucket
:
''
,
access_key_id
:
''
,
secret_access_key
:
''
,
prefix
:
'
backups/
'
,
force_path_style
:
false
,
}
)
const
s3SecretConfigured
=
ref
(
false
)
const
savingS3
=
ref
(
false
)
const
testingS3
=
ref
(
false
)
// Schedule config
const
scheduleForm
=
ref
<
BackupScheduleConfig
>
({
enabled
:
false
,
cron_expr
:
'
0 2 * * *
'
,
retain_days
:
14
,
retain_count
:
10
,
}
)
const
savingSchedule
=
ref
(
false
)
// Backups
const
backups
=
ref
<
BackupRecord
[]
>
([])
const
loadingBackups
=
ref
(
false
)
const
creatingBackup
=
ref
(
false
)
const
restoringId
=
ref
(
''
)
const
manualExpireDays
=
ref
(
14
)
// R2 guide
const
showR2Guide
=
ref
(
false
)
const
r2ConfigRows
=
computed
(()
=>
[
{
field
:
t
(
'
admin.backup.s3.endpoint
'
),
value
:
'
https://<account_id>.r2.cloudflarestorage.com
'
}
,
{
field
:
t
(
'
admin.backup.s3.region
'
),
value
:
'
auto
'
}
,
{
field
:
t
(
'
admin.backup.s3.bucket
'
),
value
:
t
(
'
admin.backup.r2Guide.step4.bucketValue
'
)
}
,
{
field
:
t
(
'
admin.backup.s3.prefix
'
),
value
:
'
backups/
'
}
,
{
field
:
'
Access Key ID
'
,
value
:
t
(
'
admin.backup.r2Guide.step4.fromStep2
'
)
}
,
{
field
:
'
Secret Access Key
'
,
value
:
t
(
'
admin.backup.r2Guide.step4.fromStep2
'
)
}
,
{
field
:
t
(
'
admin.backup.s3.forcePathStyle
'
),
value
:
t
(
'
admin.backup.r2Guide.step4.unchecked
'
)
}
,
])
async
function
loadS3Config
()
{
try
{
const
cfg
=
await
adminAPI
.
backup
.
getS3Config
()
s3Form
.
value
=
{
endpoint
:
cfg
.
endpoint
||
''
,
region
:
cfg
.
region
||
'
auto
'
,
bucket
:
cfg
.
bucket
||
''
,
access_key_id
:
cfg
.
access_key_id
||
''
,
secret_access_key
:
''
,
prefix
:
cfg
.
prefix
||
'
backups/
'
,
force_path_style
:
cfg
.
force_path_style
,
}
s3SecretConfigured
.
value
=
Boolean
(
cfg
.
access_key_id
)
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
}
async
function
saveS3Config
()
{
savingS3
.
value
=
true
try
{
await
adminAPI
.
backup
.
updateS3Config
(
s3Form
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.backup.s3.saved
'
))
await
loadS3Config
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
savingS3
.
value
=
false
}
}
async
function
testS3
()
{
testingS3
.
value
=
true
try
{
const
result
=
await
adminAPI
.
backup
.
testS3Connection
(
s3Form
.
value
)
if
(
result
.
ok
)
{
appStore
.
showSuccess
(
result
.
message
||
t
(
'
admin.backup.s3.testSuccess
'
))
}
else
{
appStore
.
showError
(
result
.
message
||
t
(
'
admin.backup.s3.testFailed
'
))
}
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
testingS3
.
value
=
false
}
}
async
function
loadSchedule
()
{
try
{
const
cfg
=
await
adminAPI
.
backup
.
getSchedule
()
scheduleForm
.
value
=
{
enabled
:
cfg
.
enabled
,
cron_expr
:
cfg
.
cron_expr
||
'
0 2 * * *
'
,
retain_days
:
cfg
.
retain_days
||
14
,
retain_count
:
cfg
.
retain_count
||
10
,
}
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
}
async
function
saveSchedule
()
{
savingSchedule
.
value
=
true
try
{
await
adminAPI
.
backup
.
updateSchedule
(
scheduleForm
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.backup.schedule.saved
'
))
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
savingSchedule
.
value
=
false
}
}
async
function
loadBackups
()
{
loadingBackups
.
value
=
true
try
{
const
result
=
await
adminAPI
.
backup
.
listBackups
()
backups
.
value
=
result
.
items
||
[]
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
loadingBackups
.
value
=
false
}
}
async
function
createBackup
()
{
creatingBackup
.
value
=
true
try
{
await
adminAPI
.
backup
.
createBackup
({
expire_days
:
manualExpireDays
.
value
}
)
appStore
.
showSuccess
(
t
(
'
admin.backup.operations.backupCreated
'
))
await
loadBackups
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
creatingBackup
.
value
=
false
}
}
async
function
downloadBackup
(
id
:
string
)
{
try
{
const
result
=
await
adminAPI
.
backup
.
getDownloadURL
(
id
)
window
.
open
(
result
.
url
,
'
_blank
'
)
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
}
async
function
restoreBackup
(
id
:
string
)
{
if
(
!
window
.
confirm
(
t
(
'
admin.backup.actions.restoreConfirm
'
)))
return
const
password
=
window
.
prompt
(
t
(
'
admin.backup.actions.restorePasswordPrompt
'
))
if
(
!
password
)
return
restoringId
.
value
=
id
try
{
await
adminAPI
.
backup
.
restoreBackup
(
id
,
password
)
appStore
.
showSuccess
(
t
(
'
admin.backup.actions.restoreSuccess
'
))
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
restoringId
.
value
=
''
}
}
async
function
removeBackup
(
id
:
string
)
{
if
(
!
window
.
confirm
(
t
(
'
admin.backup.actions.deleteConfirm
'
)))
return
try
{
await
adminAPI
.
backup
.
deleteBackup
(
id
)
appStore
.
showSuccess
(
t
(
'
admin.backup.actions.deleted
'
))
await
loadBackups
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
}
)?.
message
||
t
(
'
errors.networkError
'
))
}
}
function
statusClass
(
status
:
string
):
string
{
switch
(
status
)
{
case
'
completed
'
:
return
'
bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
'
case
'
running
'
:
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
'
case
'
failed
'
:
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300
'
default
:
return
'
bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300
'
}
}
function
formatSize
(
bytes
:
number
):
string
{
if
(
!
bytes
||
bytes
<=
0
)
return
'
-
'
if
(
bytes
<
1024
)
return
`${bytes
}
B`
if
(
bytes
<
1024
*
1024
)
return
`${(bytes / 1024).toFixed(1)
}
KB`
return
`${(bytes / (1024 * 1024)).toFixed(1)
}
MB`
}
function
formatDate
(
value
?:
string
):
string
{
if
(
!
value
)
return
'
-
'
const
date
=
new
Date
(
value
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
value
return
date
.
toLocaleString
()
}
onMounted
(
async
()
=>
{
await
Promise
.
all
([
loadS3Config
(),
loadSchedule
(),
loadBackups
()])
}
)
<
/script
>
<
style
scoped
>
.
modal
-
enter
-
active
,
.
modal
-
leave
-
active
{
transition
:
opacity
0.2
s
ease
;
}
.
modal
-
enter
-
from
,
.
modal
-
leave
-
to
{
opacity
:
0
;
}
<
/style
>
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment