게시물 export 작업 기반 추가 v1.5.20

This commit is contained in:
2026-06-01 12:26:24 +09:00
parent abce690546
commit 11203ba251
13 changed files with 608 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ const savingSpam = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const uploadingHomeCoverDark = ref(false)
const requestingPostExport = ref(false)
const errorMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
@@ -80,6 +81,12 @@ let toastTimer = null
let scrollSpyFrame = null
const { data: settings } = await useFetch('/admin/api/settings')
const {
data: postExportJobs,
refresh: refreshPostExportJobs
} = await useFetch('/admin/api/posts/export-jobs', {
default: () => []
})
const form = reactive({
title: settings.value?.title || 'sori.studio',
@@ -158,6 +165,62 @@ const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
const hasSpamChanges = computed(() => editSpam.value
&& JSON.stringify(form.signupBlockedUsernames) !== JSON.stringify(spamSnapshot.signupBlockedUsernames))
/**
* 최신 게시물 export 작업 목록
* @returns {Array} export 작업 목록
*/
const normalizedPostExportJobs = computed(() => Array.isArray(postExportJobs.value) ? postExportJobs.value : [])
/**
* export 상태 라벨 조회
* @param {string} status - export 상태
* @returns {string} 상태 라벨
*/
const getPostExportStatusLabel = (status) => ({
queued: '대기 중',
processing: '생성 중',
ready: '준비 완료',
failed: '실패',
expired: '만료'
}[status] || '알 수 없음')
/**
* export 상태 배지 클래스 조회
* @param {string} status - export 상태
* @returns {string} Tailwind 클래스
*/
const getPostExportStatusClass = (status) => ({
queued: 'bg-[#eef4ff] text-[#2f5fbb] ring-[#c9dafd]',
processing: 'bg-[#fff7e8] text-[#9a6200] ring-[#f3d39b]',
ready: 'bg-[#eaf8f0] text-[#147a45] ring-[#b9e7cd]',
failed: 'bg-[#fff0f0] text-[#c53232] ring-[#f3c2c2]',
expired: 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]'
}[status] || 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]')
/**
* 바이트 값을 읽기 쉬운 용량으로 변환한다.
* @param {number} value - 바이트 값
* @returns {string} 용량 라벨
*/
const formatExportFileSize = (value) => {
const bytes = Number(value || 0)
if (!bytes) {
return '생성 대기'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
/**
* 가입 금지 닉네임 textarea 바인딩
*/
@@ -325,6 +388,37 @@ const showToast = (type, message) => {
}, 3200)
}
/**
* 게시물 export 작업을 요청한다.
* @returns {Promise<void>}
*/
const requestPostExport = async () => {
if (requestingPostExport.value) {
return
}
requestingPostExport.value = true
errorMessage.value = ''
showToast('info', '게시물 Export 작업을 등록하는 중입니다.')
try {
await $fetch('/admin/api/posts/export-jobs', {
method: 'POST',
body: {
chunkSize: 100,
retentionDays: 100
}
})
await refreshPostExportJobs()
showToast('success', '게시물 Export 작업이 등록되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '게시물 Export 작업을 등록하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
requestingPostExport.value = false
}
}
/**
* 로고 파일 선택창을 연다.
* @returns {void}
@@ -1629,11 +1723,111 @@ onBeforeUnmount(() => {
게시물 Import/Export
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
마크다운 형식으로 게시물을 가져오거나 보냅니다. (준비 )
게시물 백업을 서버 작업으로 등록하고, 준비된 분할 파일을 내려받습니다.
</p>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 일괄 가져오기·보내기 도구를 제공합니다.
<div class="admin-settings-screen__export-actions mt-5 flex flex-col gap-3 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
Obsidian 호환 백업 준비
</p>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
100 단위 분할 zip 계획을 만들고, 산출물은 최대 100 동안 보관합니다.
</p>
</div>
<button
class="admin-settings-screen__export-request inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
type="button"
:disabled="requestingPostExport"
@click="requestPostExport"
>
{{ requestingPostExport ? '요청 중...' : 'Export 요청' }}
</button>
</div>
<div class="admin-settings-screen__export-list mt-5">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-[#15171a]">
최근 Export 작업
</h3>
<button
class="inline-flex h-8 cursor-pointer items-center justify-center rounded px-3 text-xs font-semibold text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
@click="refreshPostExportJobs"
>
새로고침
</button>
</div>
<div
v-if="normalizedPostExportJobs.length === 0"
class="admin-settings-screen__export-empty rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]"
>
아직 등록된 Export 작업이 없습니다.
</div>
<div v-else class="admin-settings-screen__export-items grid gap-3">
<article
v-for="job in normalizedPostExportJobs"
:key="job.id"
class="admin-settings-screen__export-item rounded-lg border border-[#e6e8eb] bg-white p-4"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ring-inset"
:class="getPostExportStatusClass(job.status)"
>
{{ getPostExportStatusLabel(job.status) }}
</span>
<span class="text-sm font-semibold text-[#15171a]">
게시물 {{ job.postCount }}
</span>
<span class="text-sm text-[#9aa3ad]">
{{ job.files.length }} 파일
</span>
</div>
<p class="mt-2 text-xs text-[#657080]">
요청: {{ formatPostDateTime(job.createdAt) }} · 만료: {{ formatPostDateTime(job.expiresAt) }}
</p>
<p v-if="job.message" class="mt-2 text-sm text-[#657080]">
{{ job.message }}
</p>
</div>
<button
class="inline-flex h-9 shrink-0 cursor-not-allowed items-center justify-center rounded-md border border-[#dce0e5] px-3 text-xs font-semibold text-[#9aa3ad]"
type="button"
disabled
>
일괄 다운로드 준비
</button>
</div>
<div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]">
<div
v-for="file in job.files"
:key="file.id"
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#edf0f3] px-3 py-2 last:border-b-0"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-[#15171a]">
{{ file.fileName }}
</p>
<p class="mt-0.5 text-xs text-[#9aa3ad]">
{{ file.postStart }}-{{ file.postEnd }} · {{ formatExportFileSize(file.fileSizeBytes) }}
</p>
</div>
<button
class="inline-flex h-8 cursor-not-allowed items-center justify-center rounded border border-[#e1e5ea] px-3 text-xs font-semibold text-[#a6b0bb]"
type="button"
disabled
>
다운로드 대기
</button>
</div>
</div>
</article>
</div>
</div>
</section>