feat: 프론트엔드 프로젝트 우선 구조 재구성 (Phase 2-a)
- 데이터 계층 신설: types/project·useProject·project.store (프로젝트 CRUD·페이지네이션) - useTask/useMember/useActivity 경로를 /projects/:projectId/... 로 전환 - task·my-task·activity 타입/스토어 repoId/repoName → projectId/projectName - 알림 store 라우팅·문구 프로젝트화, 활동 타입 repo.linked/unlinked·project.* 반영 - 라우팅 /projects/... 전면 교체, / → /projects 리다이렉트 - 페이지 rename Repo*→Project* (List/Create/Detail/Settings/Members/Activity) - ProjectCreatePage: Gitea 저장소 폼 → 단순 프로젝트 생성 폼으로 재작성 - RepoHeader→ProjectHeader, RepoTabs→ProjectTabs, AppShell 네비 프로젝트화 - 죽은 파일 삭제: repo.store·useRepo·GiteaRepoBar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ async function onLogin() {
|
||||
password: password.value,
|
||||
keepLogin: keepLogin.value,
|
||||
})
|
||||
await router.push('/repos')
|
||||
await router.push('/projects')
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@ onMounted(() => {
|
||||
|
||||
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
|
||||
function taskLink(row: MyTaskRow): string {
|
||||
return `/repos/${row.repoId}/tasks/${row.id}`
|
||||
return `/projects/${row.projectId}/tasks/${row.id}`
|
||||
}
|
||||
|
||||
const groups = computed(() => (view.value === 'assigned' ? myTaskStore.assigned : myTaskStore.issued))
|
||||
@@ -225,7 +225,7 @@ function loadMore() {
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
{{ row.repoName }}
|
||||
{{ row.projectName }}
|
||||
</span>
|
||||
<span class="meta-sep" />
|
||||
<span
|
||||
|
||||
+15
-15
@@ -3,35 +3,35 @@
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Repo, ActivityIcon } from '@/mock/relay.mock'
|
||||
import type { Project, ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const activityStore = useActivityStore()
|
||||
|
||||
// 저장소 헤더 + 활동 피드 로딩
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function load() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
try {
|
||||
await activityStore.fetch(repoId.value)
|
||||
await activityStore.fetch(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, load, { immediate: true })
|
||||
watch(projectId, load, { immediate: true })
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
@@ -86,14 +86,14 @@ const ActIcon = defineComponent({
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -102,12 +102,12 @@ const ActIcon = defineComponent({
|
||||
<b>활동</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="repo.updatedAgo"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="activity"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
// 새 프로젝트 만들기 — 표시 제목/설명/공개 범위
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 생성 — 성공 시 새 프로젝트 상세로 이동
|
||||
async function createProject() {
|
||||
if (submitting.value) return
|
||||
if (!name.value.trim()) {
|
||||
window.alert('프로젝트 이름을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const project = await projectStore.create({
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
visibility: visibility.value,
|
||||
})
|
||||
await router.push(`/projects/${project.id}`)
|
||||
} catch {
|
||||
// 에러는 API 인터셉터에서 전역 처리
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
프로젝트
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 프로젝트
|
||||
</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 프로젝트 만들기</h1>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후
|
||||
연동할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="card"
|
||||
@submit.prevent="createProject"
|
||||
>
|
||||
<!-- 표시 제목 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 프로젝트 이름
|
||||
</div>
|
||||
<div class="fhint">
|
||||
목록과 화면에 표시되는 이름입니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: 3분기 신제품 런칭 캠페인"
|
||||
maxlength="60"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 공개 범위 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 공개 범위
|
||||
</div>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'private' }"
|
||||
type="button"
|
||||
@click="visibility = 'private'"
|
||||
>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">비공개</span>
|
||||
<span class="rc-desc">초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'public' }"
|
||||
type="button"
|
||||
@click="visibility = 'public'"
|
||||
>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">공개</span>
|
||||
<span class="rc-desc">조직 내 모든 구성원이 프로젝트를 보고 업무 현황을 열람할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
프로젝트의 목적과 범위를 간단히 적어주세요.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="예: 3분기 신제품 런칭을 위한 콘텐츠·광고 제작"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="form-footer">
|
||||
<RouterLink
|
||||
class="btn ghost"
|
||||
to="/projects"
|
||||
>
|
||||
취소
|
||||
</RouterLink>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? '생성 중…' : '프로젝트 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 47.5rem;
|
||||
}
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0;
|
||||
}
|
||||
.crumb .lnk {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumb .sep {
|
||||
color: var(--text-3);
|
||||
margin: 0 0.4375rem;
|
||||
}
|
||||
.pagehead {
|
||||
padding: 0.5625rem 0 0.375rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.fblock {
|
||||
padding: 1.375rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.tinput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tinput::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 6rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.radio-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.8125rem 0.875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.radio-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.radio-card.sel {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.rc-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rc-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rc-radio {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.radio-card.sel .rc-radio {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.radio-card.sel .rc-radio::after {
|
||||
content: '';
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
+19
-27
@@ -5,26 +5,25 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskListQuery, TaskSegment } from '@/types/task'
|
||||
import type { Repo, TaskStatus } from '@/mock/relay.mock'
|
||||
import type { Project, TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
const { tasks, counts, hasMore, loadingMore, loading } = storeToRefs(taskStore)
|
||||
|
||||
// 저장소 헤더/진행률 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -42,7 +41,7 @@ function currentQuery(): TaskListQuery {
|
||||
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
|
||||
async function loadTasks() {
|
||||
try {
|
||||
await taskStore.fetchList(repoId.value, currentQuery())
|
||||
await taskStore.fetchList(projectId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -51,7 +50,7 @@ async function loadTasks() {
|
||||
// 더보기 — 같은 조건으로 다음 페이지 이어붙이기
|
||||
async function loadMoreTasks() {
|
||||
try {
|
||||
await taskStore.loadMore(repoId.value, currentQuery())
|
||||
await taskStore.loadMore(projectId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -59,7 +58,7 @@ async function loadMoreTasks() {
|
||||
|
||||
// 저장소 전환 시 헤더 + 목록 리셋 로드
|
||||
watch(
|
||||
repoId,
|
||||
projectId,
|
||||
() => {
|
||||
loadRepo()
|
||||
loadTasks()
|
||||
@@ -108,7 +107,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
@@ -118,7 +117,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더(공통) — 진행률 스트립을 extra 슬롯으로 주입 -->
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="repo.updatedAgo"
|
||||
>
|
||||
@@ -150,18 +149,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</RepoHeader>
|
||||
|
||||
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
|
||||
<GiteaRepoBar
|
||||
:clone-url="repo.cloneUrl"
|
||||
:html-url="repo.htmlUrl"
|
||||
class="gitea-bar-spacing"
|
||||
/>
|
||||
</ProjectHeader>
|
||||
|
||||
<!-- 서브탭(공통) -->
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="tasks"
|
||||
:task-count="counts.all"
|
||||
:member-count="memberCount"
|
||||
@@ -230,7 +222,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary new-task"
|
||||
:to="`/repos/${repo.id}/tasks/new`"
|
||||
:to="`/projects/${repo.id}/tasks/new`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -268,7 +260,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ 'is-done': task.status === 'done' }"
|
||||
:to="`/repos/${repo.id}/tasks/${task.id}`"
|
||||
:to="`/projects/${repo.id}/tasks/${task.id}`"
|
||||
>
|
||||
<span
|
||||
class="st-dot"
|
||||
@@ -437,7 +429,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
+30
-82
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 목록 — 조직의 저장소를 검색/필터하여 목록으로 표시
|
||||
// 프로젝트 목록 — 검색/필터하여 목록으로 표시
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | Visibility
|
||||
@@ -13,25 +13,20 @@ type Filter = 'all' | Visibility
|
||||
const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading, owner, total, hasMore, loadingMore } = storeToRefs(repoStore)
|
||||
const projectStore = useProjectStore()
|
||||
const { projects, loading, total, hasMore, loadingMore } = storeToRefs(projectStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
void repoStore.fetchCreateMeta()
|
||||
void projectStore.fetchList()
|
||||
})
|
||||
|
||||
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
|
||||
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
|
||||
|
||||
// 검색어 + 공개여부 필터 적용
|
||||
const filteredRepos = computed(() =>
|
||||
repos.value.filter((repo) => {
|
||||
projects.value.filter((project) => {
|
||||
const matchKeyword =
|
||||
!keyword.value ||
|
||||
repo.name.includes(keyword.value) ||
|
||||
repo.slug.includes(keyword.value)
|
||||
const matchFilter = filter.value === 'all' || repo.visibility === filter.value
|
||||
!keyword.value || project.name.includes(keyword.value)
|
||||
const matchFilter =
|
||||
filter.value === 'all' || project.visibility === filter.value
|
||||
return matchKeyword && matchFilter
|
||||
}),
|
||||
)
|
||||
@@ -41,34 +36,15 @@ const filteredRepos = computed(() =>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<b>저장소</b>
|
||||
<b>프로젝트</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<h1>프로젝트</h1>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
<span
|
||||
v-if="giteaConnected"
|
||||
class="gitea-badge"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
Gitea 연동됨
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
<b>{{ owner || '내' }}</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
|
||||
전달하세요.
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -90,7 +66,7 @@ const filteredRepos = computed(() =>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="저장소 검색…"
|
||||
placeholder="프로젝트 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
@@ -128,7 +104,7 @@ const filteredRepos = computed(() =>
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
to="/repos/new"
|
||||
to="/projects/new"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
@@ -142,7 +118,7 @@ const filteredRepos = computed(() =>
|
||||
>
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
새 저장소
|
||||
새 프로젝트
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +128,7 @@ const filteredRepos = computed(() =>
|
||||
v-for="repo in filteredRepos"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
>
|
||||
<div class="repo-ico">
|
||||
<svg
|
||||
@@ -171,7 +147,10 @@ const filteredRepos = computed(() =>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span class="repo-slug">{{ repo.slug }}</span>
|
||||
<span
|
||||
v-if="repo.isSystem"
|
||||
class="repo-slug"
|
||||
>시스템</span>
|
||||
<span
|
||||
class="vis"
|
||||
:class="repo.visibility"
|
||||
@@ -208,31 +187,15 @@ const filteredRepos = computed(() =>
|
||||
</svg>
|
||||
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="repo.giteaMissing"
|
||||
class="missing-badge"
|
||||
title="Gitea 조직에서 원격 저장소를 찾을 수 없습니다."
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
원격 없음
|
||||
</span>
|
||||
</div>
|
||||
<div class="repo-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi">
|
||||
<span
|
||||
v-if="repo.repoCount > 0"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -241,25 +204,10 @@ const filteredRepos = computed(() =>
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/>
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
{{ repo.branch }}
|
||||
저장소 {{ repo.repoCount }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
@@ -307,7 +255,7 @@ const filteredRepos = computed(() =>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading && repos.length === 0"
|
||||
v-if="loading && projects.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
불러오는 중…
|
||||
@@ -316,7 +264,7 @@ const filteredRepos = computed(() =>
|
||||
v-else-if="filteredRepos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
{{ projects.length === 0 ? '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +274,7 @@ const filteredRepos = computed(() =>
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="repoStore.loadMore()"
|
||||
@click="projectStore.loadMore()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
+18
-18
@@ -3,28 +3,28 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import type { Repo } from '@/mock/relay.mock'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { MemberRoleType } from '@/types/member'
|
||||
|
||||
type RoleFilter = 'all' | 'admin' | 'member'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const memberStore = useMemberStore()
|
||||
|
||||
// 저장소 헤더 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -35,14 +35,14 @@ const members = computed(() => memberStore.members)
|
||||
const loading = computed(() => memberStore.loading)
|
||||
async function loadMembers() {
|
||||
try {
|
||||
await memberStore.fetchList(repoId.value)
|
||||
await memberStore.fetchList(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
repoId,
|
||||
projectId,
|
||||
() => {
|
||||
loadRepo()
|
||||
loadMembers()
|
||||
@@ -86,7 +86,7 @@ async function changeRole(userId: string, roleType: MemberRoleType) {
|
||||
openRoleFor.value = null
|
||||
roleBusy.value = true
|
||||
try {
|
||||
await memberStore.update(repoId.value, userId, { roleType })
|
||||
await memberStore.update(projectId.value, userId, { roleType })
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -98,7 +98,7 @@ async function changeRole(userId: string, roleType: MemberRoleType) {
|
||||
async function removeMember(userId: string, name: string) {
|
||||
if (!window.confirm(`${name} 님을 저장소에서 제거하시겠습니까?`)) return
|
||||
try {
|
||||
await memberStore.remove(repoId.value, userId)
|
||||
await memberStore.remove(projectId.value, userId)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function submitInvite() {
|
||||
if (!canSubmitInvite.value) return
|
||||
inviteBusy.value = true
|
||||
try {
|
||||
await memberStore.invite(repoId.value, {
|
||||
await memberStore.invite(projectId.value, {
|
||||
email: inviteEmail.value.trim(),
|
||||
roleType: inviteRole.value,
|
||||
})
|
||||
@@ -205,14 +205,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -221,14 +221,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<b>멤버</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="`멤버 ${counts.all}명`"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="members"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="counts.all"
|
||||
+19
-95
@@ -3,18 +3,17 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { type Repo, type Visibility } from '@/mock/relay.mock'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { type Project, type Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
|
||||
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
|
||||
const canManage = computed(() => repo.value?.canManage === true)
|
||||
@@ -24,17 +23,16 @@ const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
|
||||
// 폼 상태(초기값은 저장소 데이터)
|
||||
// 폼 상태(초기값은 프로젝트 데이터)
|
||||
const displayTitle = ref('')
|
||||
const description = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const branch = ref('main')
|
||||
const saving = ref(false)
|
||||
|
||||
// 저장소 로드 + 폼 초기화
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null
|
||||
return
|
||||
@@ -42,26 +40,24 @@ async function loadRepo() {
|
||||
if (!repo.value) return
|
||||
// 비관리자는 설정 화면에 진입할 수 없음 — 저장소 상세로 되돌린다(탭도 숨겨져 있어 정상 경로로는 도달 불가)
|
||||
if (!repo.value.canManage) {
|
||||
void router.replace(`/repos/${repoId.value}`)
|
||||
void router.replace(`/projects/${projectId.value}`)
|
||||
return
|
||||
}
|
||||
displayTitle.value = repo.value.name
|
||||
description.value = repo.value.desc
|
||||
visibility.value = repo.value.visibility
|
||||
branch.value = repo.value.branch
|
||||
}
|
||||
watch(repoId, loadRepo, { immediate: true })
|
||||
watch(projectId, loadRepo, { immediate: true })
|
||||
|
||||
// 변경 저장 (admin 전용)
|
||||
async function save() {
|
||||
if (saving.value || !repo.value || !canManage.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
repo.value = await repoStore.update(repoId.value, {
|
||||
repo.value = await projectStore.update(projectId.value, {
|
||||
name: displayTitle.value.trim(),
|
||||
description: description.value.trim(),
|
||||
visibility: visibility.value,
|
||||
branch: branch.value.trim() || 'main',
|
||||
})
|
||||
window.alert('변경 사항을 저장했습니다.')
|
||||
} catch {
|
||||
@@ -76,8 +72,8 @@ async function removeRepo() {
|
||||
if (!repo.value || !isOwner.value) return
|
||||
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
|
||||
try {
|
||||
await repoStore.remove(repoId.value)
|
||||
await router.push('/repos')
|
||||
await projectStore.remove(projectId.value)
|
||||
await router.push('/projects')
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
@@ -92,14 +88,14 @@ async function removeRepo() {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -108,14 +104,14 @@ async function removeRepo() {
|
||||
<b>설정</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="`멤버 ${memberCount}명`"
|
||||
:members="headerMembers"
|
||||
:more-count="repo.moreCount"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
@@ -163,37 +159,6 @@ async function removeRepo() {
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span>
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.
|
||||
</div>
|
||||
<div class="readonly">
|
||||
{{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
|
||||
<span class="lock">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<GiteaRepoBar
|
||||
:clone-url="repo.cloneUrl"
|
||||
:html-url="repo.htmlUrl"
|
||||
class="gitea-bar-spacing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
@@ -265,47 +230,6 @@ async function removeRepo() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
메인 브랜치
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소의 기본 브랜치 이름입니다.
|
||||
</div>
|
||||
<div class="input-ico">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/><circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/><circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/><path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="branch"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<button
|
||||
class="btn primary"
|
||||
@@ -1,846 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 새 저장소 만들기 — 저장소 생성 폼
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const repoStore = useRepoStore()
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보
|
||||
const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore)
|
||||
onMounted(() => {
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
|
||||
// 폼 상태
|
||||
const repoName = ref('')
|
||||
const displayTitle = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const description = ref('')
|
||||
// 템플릿 사용 여부 — 사용 시 설정된 템플릿 저장소(project-base)를 복제해 생성
|
||||
const useTemplate = ref(false)
|
||||
const branch = ref('main')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 이름(slug) 실시간 가용성 — 입력 형식 검사(즉시) + 중복 검사(디바운스 서버 조회)
|
||||
const SLUG_RE = /^[a-z0-9-_]+$/
|
||||
type NameStatus = 'empty' | 'invalid' | 'checking' | 'available' | 'taken'
|
||||
const nameStatus = ref<NameStatus>('empty')
|
||||
let nameTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const availMessage = computed(() => {
|
||||
switch (nameStatus.value) {
|
||||
case 'invalid':
|
||||
return '영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.'
|
||||
case 'checking':
|
||||
return '사용 가능 여부 확인 중…'
|
||||
case 'available':
|
||||
return '사용 가능한 이름입니다'
|
||||
case 'taken':
|
||||
return '이미 사용 중인 이름입니다'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 입력 변경 시: 빈 값/형식 위반은 즉시 판정, 형식 OK 면 디바운스 후 서버 중복 조회
|
||||
watch(repoName, (val) => {
|
||||
const slug = val.trim()
|
||||
if (nameTimer) clearTimeout(nameTimer)
|
||||
if (!slug) {
|
||||
nameStatus.value = 'empty'
|
||||
return
|
||||
}
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
nameStatus.value = 'invalid'
|
||||
return
|
||||
}
|
||||
nameStatus.value = 'checking'
|
||||
nameTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { available } = await repoApi.checkName(slug)
|
||||
// 그 사이 입력이 또 바뀌었으면 결과 무시(경합 방지)
|
||||
if (repoName.value.trim() !== slug) return
|
||||
nameStatus.value = available ? 'available' : 'taken'
|
||||
} catch {
|
||||
// 확인 실패 시 표시는 감춤 — 최종 검증은 제출 시 서버가 수행
|
||||
if (repoName.value.trim() === slug) nameStatus.value = 'empty'
|
||||
}
|
||||
})()
|
||||
}, 350)
|
||||
})
|
||||
|
||||
// 생성 — 성공 시 새 저장소 상세로 이동
|
||||
async function createRepo() {
|
||||
if (submitting.value) return
|
||||
|
||||
// 클라이언트 1차 검증 (서버 DTO 가 최종 검증)
|
||||
if (!repoName.value.trim()) {
|
||||
window.alert('저장소 이름을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (!/^[a-z0-9-_]+$/.test(repoName.value.trim())) {
|
||||
window.alert('저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
if (!displayTitle.value.trim()) {
|
||||
window.alert('표시 제목을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (!description.value.trim()) {
|
||||
window.alert('프로젝트 설명을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const repo = await repoStore.create({
|
||||
slug: repoName.value.trim(),
|
||||
name: displayTitle.value.trim(),
|
||||
visibility: visibility.value,
|
||||
description: description.value.trim(),
|
||||
branch: branch.value.trim() || undefined,
|
||||
useTemplate: useTemplate.value,
|
||||
})
|
||||
await router.push(`/repos/${repo.id}`)
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 저장소
|
||||
</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 저장소 만들기</h1>
|
||||
<span class="gitea-badge">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" /><path d="M15 7h2a5 5 0 0 1 0 10h-2" /><path d="M8 12h8" />
|
||||
</svg>
|
||||
Gitea 연동됨
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고
|
||||
담당자에게 전달할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="card"
|
||||
@submit.prevent="createRepo"
|
||||
>
|
||||
<!-- 소유자 / 저장소 이름 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 소유자 / 저장소 이름 <span class="fixed-pill">소유자 고정</span>
|
||||
</div>
|
||||
<div class="fhint">
|
||||
소유자는 현재 조직으로 고정되어 변경할 수 없습니다.
|
||||
</div>
|
||||
<div class="repo-id">
|
||||
<div
|
||||
class="owner-fixed"
|
||||
title="소유자는 변경할 수 없습니다"
|
||||
>
|
||||
{{ owner || '조직 불러오는 중…' }}
|
||||
<span class="lock">
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="name-wrap">
|
||||
<span class="slash">/</span>
|
||||
<input
|
||||
v-model="repoName"
|
||||
type="text"
|
||||
placeholder="repository-name"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="nameStatus !== 'empty'"
|
||||
class="avail"
|
||||
:class="nameStatus"
|
||||
>
|
||||
<svg
|
||||
v-if="nameStatus === 'available'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="nameStatus === 'invalid' || nameStatus === 'taken'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
{{ availMessage }}
|
||||
</div>
|
||||
<div class="fhint below">
|
||||
영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 표시 제목 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 표시 제목
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="displayTitle"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: 3분기 신제품 런칭 캠페인"
|
||||
maxlength="40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 공개 범위 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 공개 범위
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.
|
||||
</div>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'private' }"
|
||||
type="button"
|
||||
@click="visibility = 'private'"
|
||||
>
|
||||
<span class="rc-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">비공개</span>
|
||||
<span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'public' }"
|
||||
type="button"
|
||||
@click="visibility = 'public'"
|
||||
>
|
||||
<span class="rc-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">공개</span>
|
||||
<span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소 목록과 상단에 표시됩니다. 프로젝트의 목적과 범위를 간단히 적어주세요.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="예: 3분기 신제품 런칭을 위한 콘텐츠·광고 제작 저장소"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 템플릿 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
템플릿
|
||||
</div>
|
||||
<div class="fhint">
|
||||
템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 새 저장소에 그대로 복제됩니다.
|
||||
(기본 브랜치는 템플릿을 따릅니다)
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select v-model="useTemplate">
|
||||
<option :value="false">
|
||||
없음 (빈 저장소)
|
||||
</option>
|
||||
<option
|
||||
v-if="templateAvailable"
|
||||
:value="true"
|
||||
>
|
||||
{{ templateSlug }} 템플릿
|
||||
</option>
|
||||
</select>
|
||||
<span class="caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 브랜치 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 메인 브랜치
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소의 기본 브랜치 이름입니다.
|
||||
</div>
|
||||
<div class="input-ico branch">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/><circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/><circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/><path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="branch"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="form-footer">
|
||||
<span class="note"><span class="step">1</span> 저장소 생성 → 업무 생성 → 전달</span>
|
||||
<RouterLink
|
||||
class="btn ghost"
|
||||
to="/repos"
|
||||
>
|
||||
취소
|
||||
</RouterLink>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
{{ submitting ? '생성 중…' : '저장소 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 47.5rem;
|
||||
}
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0;
|
||||
}
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5625rem 0 0.375rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gitea-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #1b7a3d;
|
||||
background: var(--green-weak);
|
||||
border: 1px solid var(--green-border);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitea-badge svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* 폼 블록 */
|
||||
.fblock {
|
||||
padding: 1.375rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fhint.below {
|
||||
margin-top: 0.5625rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.tinput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tinput::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 6rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 소유자 / 저장소 이름 */
|
||||
.repo-id {
|
||||
display: flex;
|
||||
}
|
||||
.owner-fixed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.8125rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: not-allowed;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.owner-fixed .lock {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.name-wrap {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: #fff;
|
||||
padding: 0 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.name-wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.name-wrap .slash {
|
||||
color: var(--text-3);
|
||||
margin-right: 0.4375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.name-wrap input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
.name-wrap input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5625rem;
|
||||
}
|
||||
/* 상태별 색상 — 사용 가능(초록) / 형식 위반·중복(빨강) / 확인 중(회색) */
|
||||
.avail.available {
|
||||
color: var(--green);
|
||||
}
|
||||
.avail.invalid,
|
||||
.avail.taken {
|
||||
color: var(--red);
|
||||
}
|
||||
.avail.checking {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avail svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fixed-pill {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #e9ebef;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 공개 범위 라디오 카드 */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.radio-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.8125rem 0.875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
background 0.12s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.radio-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.radio-card.sel {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.rc-ico {
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f1f2f4;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rc-ico svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.radio-card.sel .rc-ico {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
}
|
||||
.rc-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rc-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rc-radio {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.radio-card.sel .rc-radio {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.radio-card.sel .rc-radio::after {
|
||||
content: '';
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* select */
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.select-wrap select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2.375rem 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.select-wrap .caret {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.select-wrap .caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* 아이콘 입력 */
|
||||
.input-ico {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 0 0.75rem;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.input-ico.branch {
|
||||
max-width: 17.5rem;
|
||||
}
|
||||
.input-ico:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.input-ico .ico {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.input-ico .ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.input-ico input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 2.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.form-footer .note {
|
||||
color: var(--text-3);
|
||||
font-size: 0.781rem;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-footer .note .step {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-footer .btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,26 +5,26 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
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 { Project, 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()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
|
||||
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
|
||||
const isEdit = computed(() => taskId.value !== null)
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const projectStore = useProjectStore()
|
||||
const memberStore = useMemberStore()
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
@@ -52,10 +52,10 @@ function toDateInput(iso: string): string {
|
||||
}
|
||||
|
||||
// 저장소 헤더(빵부스러기) — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null
|
||||
}
|
||||
@@ -63,7 +63,7 @@ async function loadRepo() {
|
||||
// 담당자 후보 = 저장소 멤버
|
||||
async function loadMembers() {
|
||||
try {
|
||||
await memberStore.fetchList(repoId.value)
|
||||
await memberStore.fetchList(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -72,7 +72,7 @@ async function loadMembers() {
|
||||
async function loadTaskForEdit() {
|
||||
if (taskId.value === null) return
|
||||
try {
|
||||
const t = await taskApi.get(repoId.value, taskId.value)
|
||||
const t = await taskApi.get(projectId.value, taskId.value)
|
||||
title.value = t.title
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
@@ -85,7 +85,7 @@ async function loadTaskForEdit() {
|
||||
}
|
||||
}
|
||||
watch(
|
||||
[repoId, taskId],
|
||||
[projectId, taskId],
|
||||
() => {
|
||||
loadRepo()
|
||||
loadMembers()
|
||||
@@ -175,7 +175,7 @@ async function requestAiSuggestions() {
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const { groups } = await ai.suggestChecklist({
|
||||
repoId: repoId.value,
|
||||
projectId: projectId.value,
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
})
|
||||
@@ -236,7 +236,7 @@ function removePending(index: number) {
|
||||
async function removeExisting(fileId: string) {
|
||||
if (taskId.value === null) return
|
||||
try {
|
||||
await taskApi.removeFile(repoId.value, taskId.value, fileId)
|
||||
await taskApi.removeFile(projectId.value, taskId.value, fileId)
|
||||
existingFiles.value = existingFiles.value.filter((f) => f.id !== fileId)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
@@ -247,7 +247,7 @@ async function uploadPendingFiles(tid: number): Promise<number> {
|
||||
let failed = 0
|
||||
for (const file of pendingFiles.value) {
|
||||
try {
|
||||
await taskApi.uploadFile(repoId.value, tid, file)
|
||||
await taskApi.uploadFile(projectId.value, tid, file)
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
@@ -275,7 +275,7 @@ async function submitTask() {
|
||||
let tid: number
|
||||
if (isEdit.value && taskId.value !== null) {
|
||||
// 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화
|
||||
const updated = await taskStore.update(repoId.value, taskId.value, {
|
||||
const updated = await taskStore.update(projectId.value, taskId.value, {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
@@ -286,7 +286,7 @@ async function submitTask() {
|
||||
})
|
||||
tid = updated.id
|
||||
} else {
|
||||
const created = await taskStore.create(repoId.value, {
|
||||
const created = await taskStore.create(projectId.value, {
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
assigneeIds,
|
||||
@@ -305,7 +305,7 @@ async function submitTask() {
|
||||
`파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`,
|
||||
)
|
||||
}
|
||||
router.push(`/repos/${repoId.value}/tasks/${tid}`)
|
||||
router.push(`/projects/${projectId.value}/tasks/${tid}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리(권한/검증 등)
|
||||
} finally {
|
||||
@@ -314,7 +314,7 @@ async function submitTask() {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
router.push(`/projects/${projectId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -323,14 +323,14 @@ function goBack() {
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo?.name ?? '저장소' }}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
@@ -36,7 +36,7 @@ const authStore = useAuthStore()
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
async function loadTask() {
|
||||
try {
|
||||
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
|
||||
task.value = await taskStore.fetchOne(projectId.value, taskId.value)
|
||||
} catch {
|
||||
task.value = null // 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -106,8 +106,8 @@ async function confirmDelete() {
|
||||
if (!task.value) return
|
||||
showDelete.value = false
|
||||
try {
|
||||
await taskStore.remove(repoId.value, task.value.id)
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
await taskStore.remove(projectId.value, task.value.id)
|
||||
router.push(`/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -117,7 +117,7 @@ async function confirmDelete() {
|
||||
async function approveTask() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.approve(repoId.value, task.value.id)
|
||||
task.value = await taskStore.approve(projectId.value, task.value.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -131,7 +131,7 @@ async function submitChanges() {
|
||||
const note = changesNote.value.trim()
|
||||
if (!note) return
|
||||
try {
|
||||
task.value = await taskStore.requestChanges(repoId.value, task.value.id, note)
|
||||
task.value = await taskStore.requestChanges(projectId.value, task.value.id, note)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -144,7 +144,7 @@ async function submitChanges() {
|
||||
async function changeTo(status: TaskStatus) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
|
||||
task.value = await taskStore.changeStatus(projectId.value, task.value.id, status)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -158,7 +158,7 @@ async function sendApproval() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.requestApproval(
|
||||
repoId.value,
|
||||
projectId.value,
|
||||
task.value.id,
|
||||
approvalNote.value.trim() || undefined,
|
||||
)
|
||||
@@ -221,7 +221,7 @@ async function submitReply(index: number) {
|
||||
const target = comments.value[index]
|
||||
if (!text || !target?.id || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addReply(repoId.value, task.value.id, target.id, text)
|
||||
task.value = await taskStore.addReply(projectId.value, task.value.id, target.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -232,7 +232,7 @@ async function submitComment() {
|
||||
const text = commentText.value.trim()
|
||||
if (!text || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addComment(repoId.value, task.value.id, text)
|
||||
task.value = await taskStore.addComment(projectId.value, task.value.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -261,7 +261,7 @@ async function onFileChange(e: Event) {
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
task.value = await taskStore.uploadFile(repoId.value, task.value.id, file)
|
||||
task.value = await taskStore.uploadFile(projectId.value, task.value.id, file)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -272,7 +272,7 @@ async function onFileChange(e: Event) {
|
||||
async function removeFile(att: Attachment) {
|
||||
if (!task.value || !att.id) return
|
||||
try {
|
||||
task.value = await taskStore.removeFile(repoId.value, task.value.id, att.id)
|
||||
task.value = await taskStore.removeFile(projectId.value, task.value.id, att.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -317,21 +317,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ task.repoName }}
|
||||
{{ task.projectName }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
업무
|
||||
@@ -356,7 +356,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<div class="actions">
|
||||
<RouterLink
|
||||
class="btn"
|
||||
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
|
||||
:to="`/projects/${projectId}/tasks/${task.id}/edit`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
Reference in New Issue
Block a user