feat: 프로젝트 드라이브 폴더 연동 + AI 검토 문서 전환
프로젝트 직접 업로드(project_attachments) 폐기, AI 검토 문서를 드라이브 폴더 연동으로 전환. 폴더 연동 CRUD·연동 폴더 문서에서 텍스트 지연 추출(NCP 다운로드, xlsx 포함)·AI 추천 주입. 폴더 선택 피커 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 문서의 AI 추천 반영 상태 배지
|
||||
// ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: 'ready' | 'failed' | 'unsupported'
|
||||
status: 'ready' | 'pending' | 'failed' | 'unsupported'
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -15,6 +15,11 @@ const META: Record<Props['status'], { label: string; title: string; cls: string
|
||||
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
|
||||
cls: 'ok',
|
||||
},
|
||||
pending: {
|
||||
label: '추출 대기',
|
||||
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
|
||||
cls: 'muted',
|
||||
},
|
||||
failed: {
|
||||
label: '추출 실패',
|
||||
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브 폴더 선택 모달 — 내 드라이브를 탐색해 프로젝트에 연동할 폴더를 고른다.
|
||||
import { ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { DriveCrumb, DriveFolder } from '@/types/drive'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', folder: { id: string; name: string }): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const drive = useDrive()
|
||||
const loading = ref(false)
|
||||
const breadcrumb = ref<DriveCrumb[]>([])
|
||||
const folders = ref<DriveFolder[]>([])
|
||||
|
||||
async function load(id: string | null): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await drive.list(id ?? undefined)
|
||||
breadcrumb.value = res.breadcrumb
|
||||
folders.value = res.folders
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function pick(f: DriveFolder): void {
|
||||
emit('select', { id: f.id, name: f.name })
|
||||
}
|
||||
|
||||
void load(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="연동할 폴더 선택"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #subtitle>
|
||||
<b>내 드라이브</b>의 폴더(하위 포함)를 프로젝트의 AI 검토 문서로 연동합니다.
|
||||
</template>
|
||||
|
||||
<nav class="dfp-crumb">
|
||||
<a
|
||||
v-if="breadcrumb.length"
|
||||
href="#"
|
||||
@click.prevent="load(null)"
|
||||
>드라이브</a>
|
||||
<b v-else>드라이브</b>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dfp-sep">›</span>
|
||||
<a
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
href="#"
|
||||
@click.prevent="load(c.id)"
|
||||
>{{ c.name }}</a>
|
||||
<b v-else>{{ c.name }}</b>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<p
|
||||
v-if="loading"
|
||||
class="dfp-msg"
|
||||
>
|
||||
불러오는 중…
|
||||
</p>
|
||||
<EmptyState v-else-if="folders.length === 0">
|
||||
이 위치에 하위 폴더가 없습니다. 드라이브에서 폴더를 먼저 만들어 주세요.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="dfp-list"
|
||||
>
|
||||
<div
|
||||
v-for="f in folders"
|
||||
:key="f.id"
|
||||
class="dfp-row"
|
||||
>
|
||||
<button
|
||||
class="dfp-open"
|
||||
type="button"
|
||||
title="폴더 열기"
|
||||
@click="load(f.id)"
|
||||
>
|
||||
<span class="dfp-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="dfp-name">{{ f.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
type="button"
|
||||
@click="pick(f)"
|
||||
>
|
||||
연동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dfp-crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.dfp-crumb a {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dfp-crumb a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dfp-sep {
|
||||
color: var(--text-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dfp-msg {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
.dfp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dfp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dfp-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.dfp-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dfp-open {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
.dfp-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.dfp-ico svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.dfp-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiProjectAttachment,
|
||||
ApiProjectDriveLink,
|
||||
ApiProjectDocument,
|
||||
CreateProjectPayload,
|
||||
ProjectListQuery,
|
||||
UpdateProjectPayload,
|
||||
@@ -42,22 +43,53 @@ export function useProject() {
|
||||
await api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
// 문서 첨부 업로드(관리자) — multipart/form-data
|
||||
async function uploadFile(
|
||||
// 연동된 드라이브 폴더 목록
|
||||
async function listDriveLinks(
|
||||
projectId: string,
|
||||
file: File,
|
||||
): Promise<ApiProjectAttachment> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return (await api.post(`/projects/${projectId}/files`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as unknown as ApiProjectAttachment
|
||||
): Promise<ApiProjectDriveLink[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/drive-links`,
|
||||
)) as unknown as ApiProjectDriveLink[]
|
||||
}
|
||||
|
||||
// 문서 첨부 삭제(관리자)
|
||||
async function removeFile(projectId: string, fileId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
// 드라이브 폴더 연동(관리자, 본인 소유 폴더만)
|
||||
async function linkDriveFolder(
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
permission?: 'read' | 'read-write',
|
||||
): Promise<ApiProjectDriveLink> {
|
||||
return (await api.post(`/projects/${projectId}/drive-links`, {
|
||||
folderId,
|
||||
...(permission ? { permission } : {}),
|
||||
})) as unknown as ApiProjectDriveLink
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove, uploadFile, removeFile }
|
||||
// 드라이브 폴더 연동 해제(관리자)
|
||||
async function unlinkDriveFolder(
|
||||
projectId: string,
|
||||
linkId: string,
|
||||
): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/drive-links/${linkId}`)
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서 목록(연동 폴더 내 파일)
|
||||
async function listDocuments(
|
||||
projectId: string,
|
||||
): Promise<ApiProjectDocument[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/documents`,
|
||||
)) as unknown as ApiProjectDocument[]
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
listDriveLinks,
|
||||
linkDriveFolder,
|
||||
unlinkDriveFolder,
|
||||
listDocuments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용)
|
||||
*/
|
||||
|
||||
import type { ApiProjectAttachment } from '@/types/project'
|
||||
import type { ApiProjectDriveLink, ApiProjectDocument } from '@/types/project'
|
||||
|
||||
/** 업무 상태 */
|
||||
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
|
||||
@@ -97,8 +97,10 @@ export interface Project {
|
||||
canManage?: boolean
|
||||
/** 소유자 여부 — 프로젝트 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
/** 문서 첨부 — 단건 조회에서만 채워짐 */
|
||||
attachments?: ApiProjectAttachment[]
|
||||
/** 연동된 드라이브 폴더 — 단건 조회에서만 채워짐 */
|
||||
driveLinks?: ApiProjectDriveLink[]
|
||||
/** AI 검토 대상 문서(연동 폴더 내 파일) — 단건 조회에서만 채워짐 */
|
||||
documents?: ApiProjectDocument[]
|
||||
}
|
||||
|
||||
/** 프로젝트의 업무 목록 행 */
|
||||
|
||||
@@ -4,46 +4,17 @@ import { ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useProject } from '@/composables/useProject'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { formatBytes } from '@/shared/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
const projectApi = useProject()
|
||||
const dialog = useDialog()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 첨부 문서 — 생성 전엔 메모리에 보관, 생성 직후 업로드
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
function triggerFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
function onPickFiles(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
pendingFiles.value.push(...(input.files ? Array.from(input.files) : []))
|
||||
input.value = ''
|
||||
}
|
||||
function removePending(i: number) {
|
||||
pendingFiles.value.splice(i, 1)
|
||||
}
|
||||
async function uploadPending(projectId: string): Promise<number> {
|
||||
let failed = 0
|
||||
for (const f of pendingFiles.value) {
|
||||
try {
|
||||
await projectApi.uploadFile(projectId, f)
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// 생성 — 성공 시 첨부 업로드 후 새 프로젝트 상세로 이동
|
||||
// 생성 — 성공 시 새 프로젝트 상세로 이동(문서는 설정에서 드라이브 폴더 연동으로 추가)
|
||||
async function createProject() {
|
||||
if (submitting.value) return
|
||||
if (!name.value.trim()) {
|
||||
@@ -56,13 +27,6 @@ async function createProject() {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
})
|
||||
const failed = await uploadPending(project.id)
|
||||
if (failed > 0) {
|
||||
void dialog.alert(
|
||||
`문서 ${failed}개 업로드에 실패했습니다. 프로젝트 설정에서 다시 첨부해 주세요.`,
|
||||
{ variant: 'warning' },
|
||||
)
|
||||
}
|
||||
await router.push(`/projects/${project.id}`)
|
||||
} catch {
|
||||
// 에러는 API 인터셉터에서 전역 처리
|
||||
@@ -129,45 +93,14 @@ async function createProject() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 문서 첨부 -->
|
||||
<!-- 문서 안내 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 문서
|
||||
AI 검토 문서
|
||||
</div>
|
||||
<div class="fhint">
|
||||
기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB)
|
||||
</div>
|
||||
<div class="files">
|
||||
<div
|
||||
v-for="(f, i) in pendingFiles"
|
||||
:key="i"
|
||||
class="file-row"
|
||||
>
|
||||
<span class="fr-name">{{ f.name }}</span>
|
||||
<span class="fr-size">{{ formatBytes(f.size) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="fr-x"
|
||||
title="제거"
|
||||
@click="removePending(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="file-add"
|
||||
@click="triggerFilePicker"
|
||||
>
|
||||
+ 파일 추가
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="onPickFiles"
|
||||
>
|
||||
프로젝트 생성 후 <b>설정 → AI 검토 문서</b>에서 내 드라이브 폴더를 연동하면,
|
||||
그 폴더의 문서가 AI 업무 추천에 활용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
|
||||
import { taskStatusLabel, formatBytes } from '@/shared/utils/format'
|
||||
import { toAbsoluteApiUrl } from '@/composables/useApi'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -35,6 +36,18 @@ async function loadProject() {
|
||||
}
|
||||
}
|
||||
|
||||
// 연동 문서 다운로드 — 드라이브 단기 presigned URL 발급 후 다운로드
|
||||
const driveApi = useDrive()
|
||||
async function downloadDoc(fileId: string): Promise<void> {
|
||||
const url = await driveApi.getDownloadUrl(fileId)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<TaskSegment>('all')
|
||||
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
|
||||
@@ -174,9 +187,9 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<!-- 프로젝트 문서(있을 때만, 다운로드 전용) -->
|
||||
<!-- AI 검토 문서(연동 드라이브 폴더 내 파일, 다운로드 전용) -->
|
||||
<div
|
||||
v-if="repo.attachments && repo.attachments.length"
|
||||
v-if="(repo.documents && repo.documents.length) || (repo.driveLinks && repo.driveLinks.length)"
|
||||
class="proj-docs"
|
||||
>
|
||||
<div class="pd-head">
|
||||
@@ -188,20 +201,37 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
|
||||
프로젝트 문서 <span class="pd-ct">{{ repo.attachments.length }}</span>
|
||||
AI 검토 문서 <span class="pd-ct">{{ repo.documents?.length ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="pd-list">
|
||||
<a
|
||||
v-for="a in repo.attachments"
|
||||
:key="a.id"
|
||||
<p
|
||||
v-if="repo.driveLinks && repo.driveLinks.length"
|
||||
class="pd-sub"
|
||||
>
|
||||
연동된 드라이브 폴더 {{ repo.driveLinks.length }}개의 문서를 AI 업무 추천에 활용합니다.
|
||||
</p>
|
||||
<div
|
||||
v-if="repo.documents && repo.documents.length"
|
||||
class="pd-list"
|
||||
>
|
||||
<button
|
||||
v-for="d in repo.documents"
|
||||
:key="d.id"
|
||||
class="pd-item"
|
||||
:href="toAbsoluteApiUrl(a.url)"
|
||||
download
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="downloadDoc(d.id)"
|
||||
>
|
||||
<span class="pd-name">{{ a.name }}</span>
|
||||
<span class="pd-size">{{ formatBytes(a.size) }}</span>
|
||||
</a>
|
||||
<span class="pd-name">{{ d.name }}</span>
|
||||
<AttachmentAiBadge :status="d.aiStatus" />
|
||||
<span class="pd-size">{{ formatBytes(d.size) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="pd-empty"
|
||||
>
|
||||
연동 폴더에 문서가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -518,6 +548,15 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pd-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin: -0.25rem 0 0.625rem;
|
||||
}
|
||||
.pd-empty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pd-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -529,6 +568,9 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.pd-item:hover {
|
||||
background: #fafbfc;
|
||||
|
||||
@@ -5,14 +5,12 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
|
||||
import DriveFolderPicker from '@/components/drive/DriveFolderPicker.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useProject } from '@/composables/useProject'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { formatBytes } from '@/shared/utils/format'
|
||||
import { toAbsoluteApiUrl } from '@/composables/useApi'
|
||||
import { type Project } from '@/mock/relay.mock'
|
||||
import type { ApiProjectAttachment } from '@/types/project'
|
||||
import type { ApiProjectDriveLink } from '@/types/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -22,10 +20,10 @@ const projectApi = useProject()
|
||||
const dialog = useDialog()
|
||||
|
||||
const repo = ref<Project | null>(null)
|
||||
// 첨부는 폼 미저장 입력을 보존하기 위해 별도 ref 로 관리(업/삭제 시 부분 갱신)
|
||||
const attachments = ref<ApiProjectAttachment[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileBusy = ref(false)
|
||||
// 연동 폴더는 폼 미저장 입력 보존을 위해 별도 ref 로 관리(연동/해제 시 부분 갱신)
|
||||
const driveLinks = ref<ApiProjectDriveLink[]>([])
|
||||
const pickerOpen = ref(false)
|
||||
const linkBusy = ref(false)
|
||||
|
||||
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
|
||||
const canManage = computed(() => repo.value?.canManage === true)
|
||||
@@ -56,48 +54,39 @@ async function loadProject() {
|
||||
}
|
||||
displayTitle.value = repo.value.name
|
||||
description.value = repo.value.desc
|
||||
attachments.value = repo.value.attachments ?? []
|
||||
driveLinks.value = repo.value.driveLinks ?? []
|
||||
}
|
||||
watch(projectId, loadProject, { immediate: true })
|
||||
|
||||
// --- 문서 첨부 (관리자) ---
|
||||
function triggerFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function onPickFiles(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
if (!files.length || fileBusy.value) return
|
||||
fileBusy.value = true
|
||||
let failed = 0
|
||||
for (const f of files) {
|
||||
try {
|
||||
attachments.value = [
|
||||
await projectApi.uploadFile(projectId.value, f),
|
||||
...attachments.value,
|
||||
]
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
// --- 드라이브 폴더 연동 (관리자) ---
|
||||
// 폴더 선택 → 연동 생성. 이미 연동/권한오류 등은 인터셉터가 알림 처리.
|
||||
async function onPickFolder(folder: { id: string; name: string }) {
|
||||
pickerOpen.value = false
|
||||
if (linkBusy.value) return
|
||||
// 중복 연동 방지(클라이언트 단)
|
||||
if (driveLinks.value.some((l) => l.folder.id === folder.id)) {
|
||||
void dialog.alert('이미 연동된 폴더입니다.', { variant: 'warning' })
|
||||
return
|
||||
}
|
||||
fileBusy.value = false
|
||||
if (failed > 0) {
|
||||
void dialog.alert(`문서 ${failed}개 업로드에 실패했습니다.`, {
|
||||
variant: 'warning',
|
||||
})
|
||||
linkBusy.value = true
|
||||
try {
|
||||
const link = await projectApi.linkDriveFolder(projectId.value, folder.id)
|
||||
driveLinks.value = [link, ...driveLinks.value]
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
} finally {
|
||||
linkBusy.value = false
|
||||
}
|
||||
}
|
||||
async function removeAttachment(id: string) {
|
||||
const ok = await dialog.confirm('이 문서를 삭제할까요?', {
|
||||
title: '문서 삭제',
|
||||
variant: 'danger',
|
||||
confirmText: '삭제',
|
||||
})
|
||||
async function removeLink(link: ApiProjectDriveLink) {
|
||||
const ok = await dialog.confirm(
|
||||
`'${link.folder.name}' 폴더 연동을 해제할까요? (드라이브의 폴더·파일은 삭제되지 않습니다)`,
|
||||
{ title: '연동 해제', variant: 'danger', confirmText: '해제' },
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectApi.removeFile(projectId.value, id)
|
||||
attachments.value = attachments.value.filter((a) => a.id !== id)
|
||||
await projectApi.unlinkDriveFolder(projectId.value, link.id)
|
||||
driveLinks.value = driveLinks.value.filter((l) => l.id !== link.id)
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
@@ -242,57 +231,60 @@ async function removeProject() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 -->
|
||||
<!-- AI 검토 문서 (드라이브 폴더 연동) -->
|
||||
<h2 class="sec-title">
|
||||
문서
|
||||
AI 검토 문서
|
||||
</h2>
|
||||
<div class="card">
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 문서
|
||||
드라이브 폴더 연동
|
||||
</div>
|
||||
<div class="fhint">
|
||||
기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB)
|
||||
내 드라이브의 폴더를 연동하면, 그 폴더(하위 포함)의 문서가 AI 업무 추천에 활용됩니다.
|
||||
연동된 폴더의 문서는 프로젝트 멤버가 열람·다운로드할 수 있습니다.
|
||||
</div>
|
||||
<div class="files">
|
||||
<a
|
||||
v-for="a in attachments"
|
||||
:key="a.id"
|
||||
<div
|
||||
v-for="l in driveLinks"
|
||||
:key="l.id"
|
||||
class="file-row"
|
||||
:href="toAbsoluteApiUrl(a.url)"
|
||||
download
|
||||
>
|
||||
<span class="fr-name">{{ a.name }}</span>
|
||||
<AttachmentAiBadge :status="a.aiStatus" />
|
||||
<span class="fr-size">{{ formatBytes(a.size) }}</span>
|
||||
<span class="fr-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="fr-name">{{ l.folder.name }}</span>
|
||||
<span class="fr-size">문서 {{ l.documentCount }}개</span>
|
||||
<button
|
||||
type="button"
|
||||
class="fr-x"
|
||||
title="삭제"
|
||||
@click.prevent="removeAttachment(a.id)"
|
||||
>×</button>
|
||||
</a>
|
||||
title="연동 해제"
|
||||
@click="removeLink(l)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="attachments.length === 0"
|
||||
v-if="driveLinks.length === 0"
|
||||
class="files-empty"
|
||||
>
|
||||
첨부된 문서가 없습니다.
|
||||
연동된 폴더가 없습니다.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="file-add"
|
||||
:disabled="fileBusy"
|
||||
@click="triggerFilePicker"
|
||||
:disabled="linkBusy"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ fileBusy ? '업로드 중…' : '+ 파일 추가' }}
|
||||
{{ linkBusy ? '연동 중…' : '+ 폴더 연동' }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="onPickFiles"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +319,13 @@ async function removeProject() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드라이브 폴더 선택 모달 -->
|
||||
<DriveFolderPicker
|
||||
v-if="pickerOpen"
|
||||
@select="onPickFolder"
|
||||
@close="pickerOpen = false"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -536,6 +535,20 @@ textarea.tarea {
|
||||
.file-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.fr-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.4375rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.fr-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.fr-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -42,7 +42,8 @@ function toProjectView(api: ApiProject): ProjectView {
|
||||
progressLabel: prog.label,
|
||||
canManage: api.canManage,
|
||||
isOwner: api.isOwner,
|
||||
attachments: api.attachments,
|
||||
driveLinks: api.driveLinks,
|
||||
documents: api.documents,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 프로젝트 문서 첨부(API 원시) — 백엔드 ProjectAttachmentResponse 와 1:1
|
||||
export interface ApiProjectAttachment {
|
||||
id: string
|
||||
// 문서 AI 반영 상태 — ready: 반영됨 / failed: 추출 실패 / unsupported: 미지원 형식 / pending: 추출 대기
|
||||
export type ProjectDocAiStatus = 'ready' | 'failed' | 'unsupported' | 'pending'
|
||||
|
||||
// 프로젝트에 연동된 드라이브 폴더(API 원시) — 백엔드 ProjectDriveLinkResponse 와 1:1
|
||||
export interface ApiProjectDriveLink {
|
||||
id: string // 연동(link) ID
|
||||
folder: { id: string; name: string; path: string }
|
||||
permission: 'read' | 'read-write'
|
||||
linkedBy: { id: string; name: string; avatarUrl: string | null } | null
|
||||
documentCount: number // 연동 폴더(하위 포함) 내 파일 수
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서(연동 폴더 내 드라이브 파일, API 원시) — 백엔드 ProjectDocumentResponse 와 1:1
|
||||
export interface ApiProjectDocument {
|
||||
id: string // 드라이브 파일 ID(다운로드는 드라이브 API 사용)
|
||||
name: string
|
||||
size: number
|
||||
kind: 'pdf' | 'zip' | 'doc' | 'img'
|
||||
url: string // 다운로드 경로(/api/...)
|
||||
uploader: { id: string; name: string; avatarUrl: string | null } | null
|
||||
createdAt: string
|
||||
// AI 추천 반영 상태 — ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
aiStatus: 'ready' | 'failed' | 'unsupported'
|
||||
aiStatus: ProjectDocAiStatus
|
||||
}
|
||||
|
||||
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1
|
||||
@@ -25,10 +34,11 @@ export interface ApiProject {
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
// 단건 조회에서만 — 현재 사용자 권한 + 문서 첨부
|
||||
// 단건 조회에서만 — 현재 사용자 권한 + 연동 폴더/문서
|
||||
canManage?: boolean // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean // 소유자 (프로젝트 삭제)
|
||||
attachments?: ApiProjectAttachment[]
|
||||
driveLinks?: ApiProjectDriveLink[]
|
||||
documents?: ApiProjectDocument[]
|
||||
}
|
||||
|
||||
// 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)
|
||||
|
||||
Reference in New Issue
Block a user