数据表
This commit is contained in:
1 parent
88e5707ab6
commit
5d5fecde1b
4 files changed
+140
No files matched your search
@@ -310,6 +310,30 @@ watch(isAuthenticated, async (authed) => {
|
|||||||
await loadStorage()
|
await loadStorage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const exportingCsv = ref(false)
|
||||||
|
|
||||||
|
async function exportDatabaseCsv() {
|
||||||
|
if (exportingCsv.value) return
|
||||||
|
exportingCsv.value = true
|
||||||
|
try {
|
||||||
|
const blob = await $fetch<Blob>('/api/admin/export/csv', {
|
||||||
|
credentials: 'include',
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `pichost-db-${new Date().toISOString().slice(0, 10)}.tar.gz`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.add({ title: t('storage.exportCsvStarted'), color: 'success' })
|
||||||
|
} catch {
|
||||||
|
toast.add({ title: t('storage.exportCsvFailed'), color: 'error' })
|
||||||
|
} finally {
|
||||||
|
exportingCsv.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -407,6 +431,31 @@ watch(isAuthenticated, async (authed) => {
|
|||||||
|
|
||||||
<StorageSupportedBackends />
|
<StorageSupportedBackends />
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-default bg-elevated p-5 sm:p-6">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-file-archive"
|
||||||
|
class="size-5 text-primary"
|
||||||
|
/>
|
||||||
|
{{ t('storage.exportTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm leading-relaxed text-muted">
|
||||||
|
{{ t('storage.exportDescription') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
:label="t('storage.exportCsv')"
|
||||||
|
icon="i-lucide-download"
|
||||||
|
color="primary"
|
||||||
|
:loading="exportingCsv"
|
||||||
|
class="shrink-0"
|
||||||
|
@click="exportDatabaseCsv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-error/30 bg-error/5 p-5 sm:p-6">
|
<section class="rounded-2xl border border-error/30 bg-error/5 p-5 sm:p-6">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|||||||
@@ -589,6 +589,11 @@
|
|||||||
"badgeLocal": "Local",
|
"badgeLocal": "Local",
|
||||||
"defaultStorageTip": "All new images, videos, and files are saved to “{name}” by default. Use “Set as default” on another backend to change this.",
|
"defaultStorageTip": "All new images, videos, and files are saved to “{name}” by default. Use “Set as default” on another backend to change this.",
|
||||||
"clearStorage": "Clear storage",
|
"clearStorage": "Clear storage",
|
||||||
|
"exportTitle": "Export database tables",
|
||||||
|
"exportDescription": "Export every table as CSV and download them as a tar.gz archive. Passwords and tokens are masked.",
|
||||||
|
"exportCsv": "Export CSV archive",
|
||||||
|
"exportCsvStarted": "Export finished, downloading",
|
||||||
|
"exportCsvFailed": "Export failed",
|
||||||
"clearStorageTitle": "Clear my storage",
|
"clearStorageTitle": "Clear my storage",
|
||||||
"clearStorageDescription": "Permanently delete every image, video, file, and cache uploaded by the current account without affecting other users.",
|
"clearStorageDescription": "Permanently delete every image, video, file, and cache uploaded by the current account without affecting other users.",
|
||||||
"clearStorageWarning": "High-risk operation: this will clear all files and caches in storage and cannot be undone!",
|
"clearStorageWarning": "High-risk operation: this will clear all files and caches in storage and cannot be undone!",
|
||||||
|
|||||||
@@ -589,6 +589,11 @@
|
|||||||
"badgeLocal": "本地",
|
"badgeLocal": "本地",
|
||||||
"defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
|
"defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
|
||||||
"clearStorage": "清空存储",
|
"clearStorage": "清空存储",
|
||||||
|
"exportTitle": "导出数据库表",
|
||||||
|
"exportDescription": "把数据库中的每张表导出为 CSV,打包成 tar.gz 压缩包下载。密码、令牌等敏感字段会脱敏。",
|
||||||
|
"exportCsv": "导出 CSV 压缩包",
|
||||||
|
"exportCsvStarted": "导出完成,开始下载",
|
||||||
|
"exportCsvFailed": "导出失败",
|
||||||
"clearStorageTitle": "清空我的存储",
|
"clearStorageTitle": "清空我的存储",
|
||||||
"clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。",
|
"clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。",
|
||||||
"clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!",
|
"clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import * as tar from 'tar'
|
||||||
|
import { requireAdminAuth } from '../../../utils/access'
|
||||||
|
import { getDb } from '../../../utils/db'
|
||||||
|
import { logInfo } from '../../../utils/logger'
|
||||||
|
|
||||||
|
/** CSV 字段转义 */
|
||||||
|
function escapeCsv(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
const text = String(value)
|
||||||
|
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 敏感字段脱敏:密码哈希、会话令牌、API Token、存储密钥 */
|
||||||
|
function maskValue(column: string, value: unknown): unknown {
|
||||||
|
if (value === null || value === undefined) return value
|
||||||
|
const text = String(value)
|
||||||
|
if (!text) return value
|
||||||
|
if (column === 'password_hash' || column === 'token_hash' || column === 'api_token') {
|
||||||
|
return `${text.slice(0, 6)}…(${text.length})`
|
||||||
|
}
|
||||||
|
if (column === 'secret_json') return '{"hidden":true}'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const workDir = join(tmpdir(), `pichost-csv-${Date.now()}`)
|
||||||
|
const outDir = join(workDir, 'csv')
|
||||||
|
const archiveName = `pichost-db-${new Date().toISOString().slice(0, 10)}.tar.gz`
|
||||||
|
|
||||||
|
await fs.mkdir(outDir, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = db.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY name
|
||||||
|
`).all() as Array<{ name: string }>
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const rows = db.prepare(`SELECT * FROM "${table.name}"`).all() as Array<Record<string, unknown>>
|
||||||
|
const columns = rows.length
|
||||||
|
? Object.keys(rows[0]!)
|
||||||
|
: (db.prepare(`PRAGMA table_info("${table.name}")`).all() as Array<{ name: string }>)
|
||||||
|
.map(column => column.name)
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
columns.map(column => escapeCsv(column)).join(','),
|
||||||
|
...rows.map(row => columns
|
||||||
|
.map(column => escapeCsv(maskValue(column, row[column])))
|
||||||
|
.join(','))
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加 BOM,Excel 打开中文不乱码
|
||||||
|
await fs.writeFile(
|
||||||
|
join(outDir, `${table.name}.csv`),
|
||||||
|
`\uFEFF${lines.join('\r\n')}`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await tar.c(
|
||||||
|
{ gzip: true, file: join(workDir, archiveName), cwd: outDir, portable: true },
|
||||||
|
['.']
|
||||||
|
)
|
||||||
|
|
||||||
|
const buffer = await fs.readFile(join(workDir, archiveName))
|
||||||
|
logInfo('database csv exported', { tables: tables.length, bytes: buffer.byteLength })
|
||||||
|
|
||||||
|
setHeader(event, 'Content-Type', 'application/gzip')
|
||||||
|
setHeader(event, 'Content-Disposition', `attachment; filename="${archiveName}"`)
|
||||||
|
return buffer
|
||||||
|
} finally {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user