From 57a88ff741d57f26747cb973c7def6e434ffed94 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 17:18:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=AC=B4=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20(=EC=A0=9C=EC=B6=9C=20=EC=8B=9C?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업로드 API 가 기존 taskId 종속이라 막혀 있던 생성 화면 첨부를 2단계 제출로 해결. 선택한 파일을 메모리에 보유하다 업무 생성/수정 후 그 taskId 로 순차 업로드(베스트에포트, 실패 개수 안내). 수정 모드는 기존 첨부 표시·즉시 삭제 + 새 파일 업로드. 25MB 사전 필터. 백엔드 변경 없음(기존 업로드 엔드포인트 재사용). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/pages/relay/TaskCreatePage.vue | 169 +++++++++++++++++++- 1 file changed, 164 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index 3ee2a34..06f579d 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -11,8 +11,10 @@ import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' import { useTask } from '@/composables/useTask' import { useAi } from '@/composables/useAi' +import { attachmentKindOf, fileBadge, formatBytes } from '@/shared/utils/format' import type { Repo, ChecklistItem, User } from '@/mock/relay.mock' import type { ApiMember } from '@/types/repo' +import type { ApiAttachment } from '@/types/task' import type { ChecklistSuggestionGroup } from '@/types/ai' const route = useRoute() @@ -77,6 +79,7 @@ async function loadTaskForEdit() { assignees.value = t.assignees.map(toUserView) // id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지) checklist.value = t.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })) + existingFiles.value = t.files } catch { // 404 등은 인터셉터가 알림 처리 } @@ -200,6 +203,58 @@ function dismissSuggestions() { aiError.value = null } +// --- 첨부파일 --- +// 생성 화면엔 업무가 아직 없어 바로 업로드 못 함 → 선택한 파일을 들고 있다가 제출 시 업로드. +// 수정 모드에선 기존 첨부(existingFiles)도 표시하고, 새 파일은 저장 시 업로드한다. +const MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB (백엔드 제한과 일치) +const existingFiles = ref([]) +const pendingFiles = ref([]) +const fileInput = ref(null) +const totalFileCount = computed( + () => existingFiles.value.length + pendingFiles.value.length, +) +function fileBadgeFor(name: string) { + return fileBadge(attachmentKindOf(name)) +} +function triggerFilePicker() { + fileInput.value?.click() +} +function onPickFiles(e: Event) { + const input = e.target as HTMLInputElement + const picked = input.files ? Array.from(input.files) : [] + const ok = picked.filter((f) => f.size <= MAX_FILE_SIZE) + pendingFiles.value.push(...ok) + if (ok.length < picked.length) { + window.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`) + } + input.value = '' // 같은 파일을 다시 선택할 수 있도록 초기화 +} +function removePending(index: number) { + pendingFiles.value.splice(index, 1) +} +// 수정 모드: 기존 첨부 즉시 삭제(API) +async function removeExisting(fileId: string) { + if (taskId.value === null) return + try { + await taskApi.removeFile(repoId.value, taskId.value, fileId) + existingFiles.value = existingFiles.value.filter((f) => f.id !== fileId) + } catch { + // 인터셉터가 알림 처리 + } +} +// 업무 생성/수정 후 대기 중인 파일들을 업로드(베스트에포트) → 실패 개수 반환 +async function uploadPendingFiles(tid: number): Promise { + let failed = 0 + for (const file of pendingFiles.value) { + try { + await taskApi.uploadFile(repoId.value, tid, file) + } catch { + failed += 1 + } + } + return failed +} + // --- 제출 --- const submitting = ref(false) const canSubmit = computed( @@ -217,6 +272,7 @@ async function submitTask() { const assigneeIds = assignees.value.map((a) => a.id) const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null + let tid: number if (isEdit.value && taskId.value !== null) { // 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화 const updated = await taskStore.update(repoId.value, taskId.value, { @@ -228,7 +284,7 @@ async function submitTask() { c.id ? { id: c.id, text: c.text } : { text: c.text }, ), }) - router.push(`/repos/${repoId.value}/tasks/${updated.id}`) + tid = updated.id } else { const created = await taskStore.create(repoId.value, { title: title.value.trim(), @@ -239,8 +295,17 @@ async function submitTask() { ? checklist.value.map((c) => ({ text: c.text })) : undefined, }) - router.push(`/repos/${repoId.value}/tasks/${created.id}`) + tid = created.id } + + // 선택한 파일 업로드(업무가 생성/확정된 뒤에야 가능) — 베스트에포트 + const failed = await uploadPendingFiles(tid) + if (failed > 0) { + window.alert( + `파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`, + ) + } + router.push(`/repos/${repoId.value}/tasks/${tid}`) } catch { // 인터셉터가 알림 처리(권한/검증 등) } finally { @@ -669,10 +734,90 @@ function goBack() {
- 첨부파일 · 5단계 예정 + 첨부파일 · {{ totalFileCount }}개
-
- 파일 첨부는 곧 제공됩니다 +
+ +
+ {{ fileBadgeFor(f.name).label }} + + {{ f.name }} + {{ formatBytes(f.size) }} + + + + +
+ +
+ {{ fileBadgeFor(f.name).label }} + + {{ f.name }} + {{ formatBytes(f.size) }} · 업로드 대기 + + + + +
+
+ + +
+ 최대 25MB · 이미지·문서·압축 파일. 지시를 보내면 함께 업로드됩니다.
@@ -1352,11 +1497,13 @@ function goBack() { /* 첨부파일 */ .dropzone { + width: 100%; border: 1.5px dashed var(--border-strong); border-radius: var(--radius); padding: 0.8125rem; text-align: center; color: var(--text-3); + font-family: inherit; font-size: 0.781rem; cursor: pointer; background: #fafbfc; @@ -1406,6 +1553,9 @@ function goBack() { .fi-doc { background: #3d8bf2; } +.fi-img { + background: #2aa775; +} .file-info { min-width: 0; flex: 1; @@ -1438,6 +1588,15 @@ function goBack() { background: #eceef1; color: var(--red); } +.file-input-hidden { + display: none; +} +.file-hint { + margin-top: 0.4375rem; + font-size: 0.719rem; + color: var(--text-3); + line-height: 1.45; +} /* 지시자 */ .issuer {