릴리스: v1.4.32 내부 이름층 topic/template 정리 마감
This commit is contained in:
375
frontend/src/composables/useAdminTemplateManager.js
Normal file
375
frontend/src/composables/useAdminTemplateManager.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminTemplateManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedTemplateId,
|
||||
selectedTemplate,
|
||||
uploadFiles,
|
||||
uploadItemDrafts,
|
||||
thumbFile,
|
||||
itemPreviewUrls,
|
||||
itemFileInput,
|
||||
templateItemListEl,
|
||||
templateItemSortable,
|
||||
savedTemplateItemOrderIds,
|
||||
isTemplateLoading,
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
customItemModalTargetTemplateId,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
newTemplateIsPublic,
|
||||
clearPreviewUrl,
|
||||
resetFileInput,
|
||||
resetUploadState,
|
||||
refreshTemplates,
|
||||
closeTemplateCreateModal,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function normalizeDraftSrc(src) {
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
if (!raw) return ''
|
||||
if (raw.startsWith('/uploads/')) return raw
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
return url.pathname || raw
|
||||
} catch (e) {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function requestItemFilename(item = {}) {
|
||||
const src = typeof item.src === 'string' ? item.src : ''
|
||||
return src.split('/').pop() || item.file?.name || 'item'
|
||||
}
|
||||
|
||||
function destroyTemplateItemSortable() {
|
||||
if (templateItemSortable.value) {
|
||||
templateItemSortable.value.destroy()
|
||||
templateItemSortable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTemplateItemSortable() {
|
||||
await nextTick()
|
||||
destroyTemplateItemSortable()
|
||||
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-template-item-id]',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: false,
|
||||
filter: '[data-no-drag]',
|
||||
preventOnFilter: false,
|
||||
fallbackClass: 'thumbCard--dragging',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextItems = [...(selectedTemplate.value?.items || [])]
|
||||
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||
nextItems.splice(evt.newIndex, 0, moved)
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: nextItems,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mergeRequestItemsIntoDrafts(request) {
|
||||
const requestId = request?.id
|
||||
if (!requestId) return
|
||||
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||
const nextRequestDrafts = (request.items || [])
|
||||
.filter((item) => item?.src)
|
||||
.map((item) => ({
|
||||
kind: 'request',
|
||||
requestId,
|
||||
itemId: item.id,
|
||||
previewUrl: toApiUrl(item.src),
|
||||
label: item.label || '',
|
||||
sourceName: requestItemFilename(item),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||
|
||||
if (nextRequestDrafts.length) {
|
||||
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
|
||||
}
|
||||
}
|
||||
|
||||
function removeUploadDraft(targetDraft) {
|
||||
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
|
||||
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
|
||||
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
|
||||
return currentKey !== targetKey
|
||||
})
|
||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadTemplate(options = {}) {
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
if (!preserveUploadState) resetUploadState()
|
||||
|
||||
if (!selectedTemplateId.value) {
|
||||
selectedTemplate.value = null
|
||||
savedTemplateItemOrderIds.value = []
|
||||
destroyTemplateItemSortable()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isTemplateLoading.value = true
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
const loadedTemplate = data.template || data.topic || null
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
template: loadedTemplate,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
} catch (e) {
|
||||
selectedTemplate.value = null
|
||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isTemplateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemplate(options = {}) {
|
||||
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
|
||||
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: nextTopicId,
|
||||
name: nextTopicName,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const data = await res.json()
|
||||
const createdTemplate = data.template || {}
|
||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
|
||||
topicId: createdTemplate.id,
|
||||
})
|
||||
activeTemplateRequest.value = {
|
||||
...activeTemplateRequest.value,
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
}
|
||||
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
||||
if (requestIndex >= 0) {
|
||||
templateRequests.value.splice(requestIndex, 1, {
|
||||
...templateRequests.value[requestIndex],
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
})
|
||||
}
|
||||
}
|
||||
await refreshTemplates()
|
||||
selectedTemplateId.value = createdTemplate.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id
|
||||
closeTemplateCreateModal()
|
||||
await loadTemplate({ preserveUploadState })
|
||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||
}
|
||||
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
|
||||
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
|
||||
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
|
||||
previousFileDrafts.forEach((draft) => {
|
||||
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
uploadFiles.value = files
|
||||
uploadItemDrafts.value = requestDrafts
|
||||
if (!files.length) {
|
||||
resetFileInput('item')
|
||||
return
|
||||
}
|
||||
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||
const fileDrafts = files.map((file, index) => ({
|
||||
kind: 'file',
|
||||
file,
|
||||
previewUrl: itemPreviewUrls.value[index],
|
||||
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
|
||||
sourceName: file.name,
|
||||
}))
|
||||
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
function onFile(event) {
|
||||
handleItemFiles(event.target.files)
|
||||
}
|
||||
|
||||
function openItemFilePicker() {
|
||||
itemFileInput.value?.click()
|
||||
}
|
||||
|
||||
function clearItemFiles() {
|
||||
uploadFiles.value = []
|
||||
uploadItemDrafts.value = []
|
||||
itemPreviewUrls.value.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
async function uploadItem() {
|
||||
resetMessages()
|
||||
if (!uploadItemDrafts.value.length) {
|
||||
error.value = '아이템 파일을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
|
||||
const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim()
|
||||
const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim()
|
||||
if (!draftTopicId || !draftTopicName) {
|
||||
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||
return
|
||||
}
|
||||
await createTemplate({
|
||||
topicId: draftTopicId,
|
||||
topicName: draftTopicName,
|
||||
preserveUploadState: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
let uploadCount = 0
|
||||
|
||||
if (fileDrafts.length) {
|
||||
const fd = new FormData()
|
||||
fileDrafts.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
uploadCount += fileDrafts.length
|
||||
}
|
||||
|
||||
if (requestDrafts.length) {
|
||||
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
topicId: selectedTemplateId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
|
||||
return acc
|
||||
}, {}),
|
||||
})
|
||||
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
|
||||
}
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
error.value = '추가할 요청 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
if (apiError === 'promote_items_failed') {
|
||||
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
||||
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
return
|
||||
}
|
||||
if (apiError === 'topic_not_found') {
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplateItemOrder() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncTemplateItemSortable()
|
||||
success.value = '기본 아이템 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestItemFilename,
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
handleItemFiles,
|
||||
onFile,
|
||||
openItemFilePicker,
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveTemplateItemOrder,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user