게시물 Export 기간 선택과 삭제 추가 v1.5.23

This commit is contained in:
2026-06-01 15:35:45 +09:00
parent f8621d49d8
commit a4c1b42369
13 changed files with 554 additions and 33 deletions

View File

@@ -21,6 +21,12 @@ const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const uploadingHomeCoverDark = ref(false)
const requestingPostExport = ref(false)
const deletingPostExportJobIds = ref([])
const postExportDateRangeMode = ref('all')
const postExportYear = ref(new Date().getFullYear())
const postExportMonth = ref(new Date().getMonth() + 1)
const postExportDateFrom = ref('')
const postExportDateTo = ref('')
const errorMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
@@ -180,11 +186,40 @@ const hasActivePostExportJobs = computed(() => normalizedPostExportJobs.value.so
job.status === 'queued' || job.status === 'processing'
)))
/**
* Export 연도 선택지
* @returns {Array<number>} 연도 목록
*/
const postExportYearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return Array.from({ length: 10 }, (_, index) => currentYear - index)
})
/**
* Export 월 선택지
* @type {ReadonlyArray<number>}
*/
const postExportMonthOptions = Array.from({ length: 12 }, (_, index) => index + 1)
/**
* 게시물 export 요청 버튼 활성 가능 여부
* @returns {boolean} 활성 가능 여부
*/
const canRequestPostExport = computed(() => !requestingPostExport.value && !hasActivePostExportJobs.value)
const canRequestPostExport = computed(() => {
if (requestingPostExport.value || hasActivePostExportJobs.value) {
return false
}
if (postExportDateRangeMode.value === 'custom') {
return Boolean(
postExportDateFrom.value
&& postExportDateTo.value
&& postExportDateFrom.value <= postExportDateTo.value
)
}
return true
})
/**
* 게시물 export 요청 버튼 안내 문구
@@ -195,9 +230,50 @@ const postExportRequestTitle = computed(() => {
return '진행 중인 Export 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
}
if (postExportDateRangeMode.value === 'custom' && !canRequestPostExport.value) {
return '올바른 시작일과 종료일을 선택해 주세요.'
}
return '게시물 Export 작업을 요청합니다.'
})
/**
* Export 요청 범위 입력을 만든다.
* @returns {Object} Export 범위 입력
*/
const createPostExportRequestBody = () => {
const base = {
chunkSize: 100,
retentionDays: 100,
dateRangeMode: postExportDateRangeMode.value
}
if (postExportDateRangeMode.value === 'year') {
return {
...base,
year: Number(postExportYear.value)
}
}
if (postExportDateRangeMode.value === 'month') {
return {
...base,
year: Number(postExportYear.value),
month: Number(postExportMonth.value)
}
}
if (postExportDateRangeMode.value === 'custom') {
return {
...base,
dateFrom: postExportDateFrom.value,
dateTo: postExportDateTo.value
}
}
return base
}
/**
* export 상태 라벨 조회
* @param {string} status - export 상태
@@ -490,10 +566,7 @@ const requestPostExport = async () => {
try {
await $fetch('/admin/api/posts/export-jobs', {
method: 'POST',
body: {
chunkSize: 100,
retentionDays: 100
}
body: createPostExportRequestBody()
})
await refreshPostExportJobs()
showToast('success', '게시물 Export 작업이 등록되었습니다.')
@@ -512,6 +585,40 @@ const requestPostExport = async () => {
*/
const getPostExportDownloadUrl = (file) => `/admin/api/posts/export-jobs/${file.id}/download`
/**
* Export 작업 삭제 중 여부
* @param {string} jobId - 작업 ID
* @returns {boolean} 삭제 중 여부
*/
const isDeletingPostExportJob = (jobId) => deletingPostExportJobIds.value.includes(jobId)
/**
* Export 작업을 삭제한다.
* @param {Object} job - Export 작업
* @returns {Promise<void>}
*/
const deletePostExportJob = async (job) => {
if (!job?.id || isDeletingPostExportJob(job.id)) {
return
}
deletingPostExportJobIds.value = [...deletingPostExportJobIds.value, job.id]
showToast('info', 'Export 백업 파일을 삭제하는 중입니다.')
try {
await $fetch(`/admin/api/posts/export-jobs/${job.id}`, {
method: 'DELETE'
})
await refreshPostExportJobs()
showToast('success', 'Export 백업 파일을 삭제했습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || 'Export 백업 파일을 삭제하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
deletingPostExportJobIds.value = deletingPostExportJobIds.value.filter((id) => id !== job.id)
}
}
/**
* 로고 파일 선택창을 연다.
* @returns {void}
@@ -1826,24 +1933,100 @@ onBeforeUnmount(() => {
</p>
</div>
<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="admin-settings-screen__export-actions mt-5 grid gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4">
<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 동안 보관합니다.
전체·연도··직접 범위로 게시물을 골라 100 단위 ZIP 백업을 만듭니다.
</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="!canRequestPostExport"
:title="postExportRequestTitle"
@click="requestPostExport"
>
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? 'Export 진행 중' : 'Export 요청' }}
</button>
<div class="grid gap-3">
<div class="admin-settings-screen__export-range grid gap-2 md:grid-cols-4">
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
범위
<select
v-model="postExportDateRangeMode"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option value="all">전체</option>
<option value="year">특정년</option>
<option value="month">특정월</option>
<option value="custom">직접 지정</option>
</select>
</label>
<label
v-if="postExportDateRangeMode === 'year' || postExportDateRangeMode === 'month'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
연도
<select
v-model.number="postExportYear"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option
v-for="year in postExportYearOptions"
:key="year"
:value="year"
>
{{ year }}
</option>
</select>
</label>
<label
v-if="postExportDateRangeMode === 'month'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
<select
v-model.number="postExportMonth"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option
v-for="month in postExportMonthOptions"
:key="month"
:value="month"
>
{{ month }}
</option>
</select>
</label>
<label
v-if="postExportDateRangeMode === 'custom'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
시작일
<input
v-model="postExportDateFrom"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="date"
>
</label>
<label
v-if="postExportDateRangeMode === 'custom'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
종료일
<input
v-model="postExportDateTo"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="date"
>
</label>
</div>
<div class="flex items-center justify-end">
<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="!canRequestPostExport"
:title="postExportRequestTitle"
@click="requestPostExport"
>
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? 'Export 진행 중' : 'Export 요청' }}
</button>
</div>
</div>
</div>
<div class="admin-settings-screen__export-list mt-5">
@@ -1886,18 +2069,31 @@ onBeforeUnmount(() => {
<span class="text-sm text-[#9aa3ad]">
{{ job.files.length }} 파일
</span>
<span class="text-sm text-[#657080]">
{{ job.rangeLabel || '전체' }}
</span>
</div>
<p class="mt-2 text-xs text-[#657080]">
요청: {{ formatPostDateTime(job.createdAt) }} · 만료: {{ formatPostDateTime(job.expiresAt) }}
</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 class="flex shrink-0 items-center gap-2">
<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>
<button
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
type="button"
:disabled="job.status === 'queued' || job.status === 'processing' || isDeletingPostExportJob(job.id)"
@click="deletePostExportJob(job)"
>
{{ isDeletingPostExportJob(job.id) ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
<div class="admin-settings-screen__export-progress mt-4 rounded-lg border border-[#edf0f3] bg-[#fbfcfd] p-3">