feat: 업무 생성 화면 첨부파일 활성화 (제출 시 업로드)
업로드 API 가 기존 taskId 종속이라 막혀 있던 생성 화면 첨부를 2단계 제출로 해결. 선택한 파일을 메모리에 보유하다 업무 생성/수정 후 그 taskId 로 순차 업로드(베스트에포트, 실패 개수 안내). 수정 모드는 기존 첨부 표시·즉시 삭제 + 새 파일 업로드. 25MB 사전 필터. 백엔드 변경 없음(기존 업로드 엔드포인트 재사용). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ApiAttachment[]>([])
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(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<number> {
|
||||
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() {
|
||||
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 <span class="muted">· 5단계 예정</span>
|
||||
첨부파일 <span
|
||||
v-if="totalFileCount"
|
||||
class="muted"
|
||||
>· {{ totalFileCount }}개</span>
|
||||
</div>
|
||||
<div class="dropzone is-disabled">
|
||||
파일 첨부는 곧 제공됩니다
|
||||
<div
|
||||
v-if="existingFiles.length || pendingFiles.length"
|
||||
class="files"
|
||||
>
|
||||
<!-- 기존 첨부(수정 모드) -->
|
||||
<div
|
||||
v-for="f in existingFiles"
|
||||
:key="f.id"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
class="file-ico"
|
||||
:class="fileBadgeFor(f.name).cls"
|
||||
>{{ fileBadgeFor(f.name).label }}</span>
|
||||
<span class="file-info">
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<span class="file-size">{{ formatBytes(f.size) }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="x"
|
||||
title="삭제"
|
||||
@click="removeExisting(f.id)"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 새로 선택(업로드 대기) -->
|
||||
<div
|
||||
v-for="(f, i) in pendingFiles"
|
||||
:key="`p${i}`"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
class="file-ico"
|
||||
:class="fileBadgeFor(f.name).cls"
|
||||
>{{ fileBadgeFor(f.name).label }}</span>
|
||||
<span class="file-info">
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<span class="file-size">{{ formatBytes(f.size) }} · 업로드 대기</span>
|
||||
</span>
|
||||
<span
|
||||
class="x"
|
||||
title="제거"
|
||||
@click="removePending(i)"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="dropzone"
|
||||
type="button"
|
||||
@click="triggerFilePicker"
|
||||
>
|
||||
<b>클릭하여 파일 선택</b>
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="file-input-hidden"
|
||||
@change="onPickFiles"
|
||||
>
|
||||
<div class="file-hint">
|
||||
최대 25MB · 이미지·문서·압축 파일. 지시를 보내면 함께 업로드됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user