refactor: 저장소(Repo)·Gitea 연동 기능 완전 제거 — 프로젝트 단독 구조
복잡도를 줄이기 위해 저장소 연동 기능을 전부 제거하고 프로젝트를 단독 단위로 운영한다. 백엔드: - modules/repo, modules/gitea 모듈 전체 삭제(엔티티/서비스/컨트롤러/DTO/역방향 동기화) - app.module 에서 RepoModule 제거 - ProjectService: repoCount 집계·repoRepo·"미분류" 시스템 프로젝트(ensureUnassigned/ onApplicationBootstrap)·isSystem 게이트 전부 제거. ProjectResponse 에서 repoCount/isSystem 제거 - Project 엔티티 is_system 컬럼 제거(dev synchronize 가 DROP) - ProjectModule forFeature 에서 Repo 제거 - Activity 타입 repo.linked/unlinked 제거 - env example 에서 Gitea/REPO_IMPORT 섹션 제거 프론트: - ProjectReposPage, useRepo 삭제. ProjectTabs 의 '저장소' 탭·repoCount prop 제거 - 4개 페이지의 :repo-count 제거, 라우트 /projects/:id/repos 제거 - types/project·project.store·mock 에서 repoCount/isSystem·Repo 뷰 제거 - ProjectListPage 저장소수/시스템 배지·gitea CSS 제거, activity 의 repo.* 케이스 제거 - types/repo 는 공용 ApiMember 만 유지 - 잔여 '저장소'/Gitea 사용자 문구를 프로젝트로 정리 검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 서브탭 — 업무/멤버/저장소/활동/설정
|
||||
// 프로젝트 서브탭 — 업무/멤버/활동/설정
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
active: 'tasks' | 'members' | 'repos' | 'activity' | 'settings'
|
||||
active: 'tasks' | 'members' | 'activity' | 'settings'
|
||||
taskCount: number
|
||||
memberCount: number
|
||||
repoCount: number
|
||||
/** 설정 탭 노출 여부(관리자만). 기본 false — 권한이 확인된 경우에만 노출 */
|
||||
canManage?: boolean
|
||||
}
|
||||
@@ -28,12 +27,6 @@ defineProps<Props>()
|
||||
>
|
||||
<span class="tb-label">멤버</span> <span class="tb-count">{{ memberCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/projects/${projectId}/repos`"
|
||||
:class="{ active: active === 'repos' }"
|
||||
>
|
||||
<span class="tb-label">저장소</span> <span class="tb-count">{{ repoCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/projects/${projectId}/activity`"
|
||||
:class="{ active: active === 'activity' }"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiRepo,
|
||||
CreateRepoPayload,
|
||||
RepoCreateMeta,
|
||||
} from '@/types/repo'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 저장소 도메인 API Composable — 프로젝트에 연동된 Gitea 저장소.
|
||||
// 모든 경로는 /projects/:projectId/repos 하위. 인터셉터가 success/data 를 언래핑.
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<Paginated<ApiRepo>> {
|
||||
return (await api.get(`/projects/${projectId}/repos`, {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiRepo>
|
||||
}
|
||||
|
||||
// 생성 폼 메타(소유자/템플릿)
|
||||
async function getCreateMeta(projectId: string): Promise<RepoCreateMeta> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/repos/meta`,
|
||||
)) as unknown as RepoCreateMeta
|
||||
}
|
||||
|
||||
// slug 사용 가능 여부 — 전역 유일 검사
|
||||
async function checkName(
|
||||
projectId: string,
|
||||
slug: string,
|
||||
): Promise<boolean> {
|
||||
const res = (await api.get(`/projects/${projectId}/repos/name-available`, {
|
||||
params: { slug },
|
||||
})) as unknown as { available: boolean }
|
||||
return res.available
|
||||
}
|
||||
|
||||
async function create(
|
||||
projectId: string,
|
||||
payload: CreateRepoPayload,
|
||||
): Promise<ApiRepo> {
|
||||
return (await api.post(
|
||||
`/projects/${projectId}/repos`,
|
||||
payload,
|
||||
)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
// 연동 해제(삭제)
|
||||
async function remove(projectId: string, repoId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/repos/${repoId}`)
|
||||
}
|
||||
|
||||
return { list, getCreateMeta, checkName, create, remove }
|
||||
}
|
||||
@@ -35,52 +35,16 @@ export interface ChecklistItem {
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/** 저장소 */
|
||||
export interface Repo {
|
||||
/** 라우팅 식별자(slug 의 마지막 segment) */
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
slug: string
|
||||
desc: string
|
||||
branch: string
|
||||
members: User[]
|
||||
/** 멤버 스택에서 추가로 더 있는 인원 수(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
/** 진행률 바 색상 단계 */
|
||||
progressLevel: 'done' | 'mid' | 'low'
|
||||
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
|
||||
progressLabel: string
|
||||
/** Gitea HTTPS clone URL (미연동 시 null/undefined) */
|
||||
cloneUrl?: string | null
|
||||
/** Gitea 웹 UI URL (미연동 시 null/undefined) */
|
||||
htmlUrl?: string | null
|
||||
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
|
||||
giteaMissing?: boolean
|
||||
/** 현재 사용자가 이 저장소 관리자(admin)인지 — 단건 조회에서만 채워짐(설정 탭 노출/진입) */
|
||||
canManage?: boolean
|
||||
/** 현재 사용자가 소유자인지 — 저장소 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
/** 프로젝트 — 작업의 기본 단위(우선). 저장소는 프로젝트에 0~N개 연동된다. */
|
||||
/** 프로젝트 — 작업의 기본 단위(우선) */
|
||||
export interface Project {
|
||||
/** 라우팅 식별자(UUID) */
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
/** "미분류" 시스템 프로젝트 */
|
||||
isSystem: boolean
|
||||
members: User[]
|
||||
/** 멤버 스택 초과분(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
/** 연동 저장소 수 */
|
||||
repoCount: number
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
|
||||
@@ -55,9 +55,9 @@ function goSocial(provider: 'google' | 'kakao') {
|
||||
|
||||
// 브랜드 패널 강조 포인트
|
||||
const points = [
|
||||
'저장소 · 업무 · 담당자로 이어지는 명확한 구조',
|
||||
'프로젝트 · 업무 · 담당자로 이어지는 명확한 구조',
|
||||
'체크리스트와 마감기한으로 진행 관리',
|
||||
'Gitea 연동으로 안전하게',
|
||||
'역할 기반 권한으로 안전하게 관리',
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -76,7 +76,7 @@ const points = [
|
||||
지시부터 완료까지,<br>팀의 업무 흐름을 하나로
|
||||
</div>
|
||||
<div class="bp-sub">
|
||||
저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
|
||||
프로젝트 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
|
||||
추적됩니다.
|
||||
</div>
|
||||
<div class="bp-points">
|
||||
|
||||
@@ -111,7 +111,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>
|
||||
모든 저장소
|
||||
모든 프로젝트
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
|
||||
@@ -111,7 +111,6 @@ const ActIcon = defineComponent({
|
||||
active="activity"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ async function createProject() {
|
||||
<h1>새 프로젝트 만들기</h1>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후
|
||||
연동할 수 있습니다.
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요.
|
||||
</p>
|
||||
|
||||
<form
|
||||
|
||||
@@ -157,7 +157,6 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
active="tasks"
|
||||
:task-count="counts.all"
|
||||
:member-count="memberCount"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
@@ -426,10 +425,6 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.gitea-bar-spacing {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
display: flex;
|
||||
|
||||
@@ -36,7 +36,7 @@ const filteredRepos = computed(() =>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 수 있습니다.
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -119,32 +119,11 @@ const filteredRepos = computed(() =>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span
|
||||
v-if="repo.isSystem"
|
||||
class="repo-slug"
|
||||
>시스템</span>
|
||||
</div>
|
||||
<div class="repo-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="repo-meta">
|
||||
<span
|
||||
v-if="repo.repoCount > 0"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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.repoCount }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
v-for="m in repo.members"
|
||||
@@ -231,25 +210,6 @@ const filteredRepos = computed(() =>
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitea-badge {
|
||||
margin-left: auto;
|
||||
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;
|
||||
@@ -383,24 +343,4 @@ const filteredRepos = computed(() =>
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
|
||||
/* 원격(Gitea) 없음 배지 */
|
||||
.missing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.missing-badge svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -232,7 +232,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
active="members"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="counts.all"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 연동 저장소 — 연동된 Gitea 저장소 목록 + 저장소 연동(생성) + 연동 해제
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
import type { ApiRepo, RepoCreateMeta } from '@/types/repo'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 프로젝트 헤더/탭 — API 연동
|
||||
const project = ref<Project | null>(null)
|
||||
async function loadProject() {
|
||||
try {
|
||||
project.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
project.value = null // 404/403 은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
const memberCount = computed(() =>
|
||||
project.value ? project.value.members.length + project.value.moreCount : 0,
|
||||
)
|
||||
const canManage = computed(() => project.value?.canManage ?? false)
|
||||
|
||||
// 연동 저장소 목록
|
||||
const repos = ref<ApiRepo[]>([])
|
||||
const loading = ref(false)
|
||||
async function loadRepos() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await repoApi.list(projectId.value)
|
||||
repos.value = res.items
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
projectId,
|
||||
() => {
|
||||
loadProject()
|
||||
loadRepos()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- 저장소 연동(생성) 폼 ---
|
||||
const showForm = ref(false)
|
||||
const meta = ref<RepoCreateMeta | null>(null)
|
||||
const slug = ref('')
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const useTemplate = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// slug 가용성 — null(미확인) / true / false
|
||||
const slugAvailable = ref<boolean | null>(null)
|
||||
const slugChecking = ref(false)
|
||||
let slugTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const slugValid = computed(() => /^[a-z0-9-_]+$/.test(slug.value.trim()))
|
||||
|
||||
// 폼 열기 — 메타(소유자/템플릿) 1회 로드
|
||||
async function openForm() {
|
||||
showForm.value = true
|
||||
if (!meta.value) {
|
||||
try {
|
||||
meta.value = await repoApi.getCreateMeta(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
slug.value = ''
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
useTemplate.value = false
|
||||
slugAvailable.value = null
|
||||
}
|
||||
|
||||
// slug 입력 디바운스 → 가용성 체크
|
||||
watch(slug, () => {
|
||||
slugAvailable.value = null
|
||||
if (slugTimer) clearTimeout(slugTimer)
|
||||
const value = slug.value.trim()
|
||||
if (!value || !slugValid.value) return
|
||||
slugChecking.value = true
|
||||
slugTimer = setTimeout(async () => {
|
||||
try {
|
||||
slugAvailable.value = await repoApi.checkName(projectId.value, value)
|
||||
} catch {
|
||||
slugAvailable.value = null
|
||||
} finally {
|
||||
slugChecking.value = false
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (slugTimer) clearTimeout(slugTimer)
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!submitting.value &&
|
||||
slugValid.value &&
|
||||
slugAvailable.value === true &&
|
||||
name.value.trim() !== '' &&
|
||||
description.value.trim() !== '',
|
||||
)
|
||||
|
||||
// 저장소 연동 — 성공 시 목록 갱신 + 폼 닫기
|
||||
async function linkRepo() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await repoApi.create(projectId.value, {
|
||||
slug: slug.value.trim(),
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
...(meta.value?.template.available ? { useTemplate: useTemplate.value } : {}),
|
||||
})
|
||||
closeForm()
|
||||
await Promise.all([loadRepos(), loadProject()])
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 연동 해제 — 확인 후 삭제, 목록 갱신
|
||||
async function unlinkRepo(repo: ApiRepo) {
|
||||
const ok = window.confirm(
|
||||
`저장소 ‘${repo.name}’의 연동을 해제합니다.\nGitea 저장소도 함께 삭제됩니다. 계속할까요?`,
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await repoApi.remove(projectId.value, repo.id)
|
||||
await Promise.all([loadRepos(), loadProject()])
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="project"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
프로젝트
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/projects/${project.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ project.name }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>저장소</b>
|
||||
</div>
|
||||
|
||||
<ProjectHeader
|
||||
:repo="project"
|
||||
:meta-text="`연동 저장소 ${project.repoCount}개`"
|
||||
:members="project.members"
|
||||
:more-count="project.moreCount"
|
||||
/>
|
||||
<ProjectTabs
|
||||
:project-id="project.id"
|
||||
active="repos"
|
||||
:task-count="project.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="project.repoCount"
|
||||
:can-manage="project.canManage"
|
||||
/>
|
||||
|
||||
<!-- 안내 + 연동 버튼 -->
|
||||
<div class="repos-head">
|
||||
<div class="tab-note">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
이 프로젝트에 코드 저장소를 연동하면 업무를 코드 변경과 연결해 관리할 수 있습니다. 한 프로젝트에 여러 저장소를 연동할 수 있습니다.
|
||||
</div>
|
||||
<button
|
||||
v-if="canManage && !showForm"
|
||||
class="btn primary"
|
||||
type="button"
|
||||
@click="openForm"
|
||||
>
|
||||
<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>
|
||||
저장소 연동
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 연동(생성) 폼 -->
|
||||
<form
|
||||
v-if="showForm"
|
||||
class="card form"
|
||||
@submit.prevent="linkRepo"
|
||||
>
|
||||
<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="40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 저장소 이름(slug)
|
||||
</div>
|
||||
<div class="fhint">
|
||||
영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용. {{ meta?.owner ? `${meta.owner}/` : '' }}이름 형태로 생성됩니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="slug"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: q3-launch-campaign"
|
||||
maxlength="60"
|
||||
autocomplete="off"
|
||||
>
|
||||
<div
|
||||
v-if="slug.trim() && !slugValid"
|
||||
class="fstatus bad"
|
||||
>
|
||||
사용할 수 없는 형식입니다.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugChecking"
|
||||
class="fstatus"
|
||||
>
|
||||
확인 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugAvailable === true"
|
||||
class="fstatus ok"
|
||||
>
|
||||
사용 가능한 이름입니다.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="slugAvailable === false"
|
||||
class="fstatus bad"
|
||||
>
|
||||
이미 사용 중인 이름입니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 설명
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="저장소의 목적을 간단히 적어주세요."
|
||||
/>
|
||||
</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="meta?.template.available"
|
||||
:value="true"
|
||||
>
|
||||
{{ meta.template.slug }} 템플릿
|
||||
</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="form-footer">
|
||||
<button
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="closeForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ submitting ? '연동 중…' : '연동' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 연동 저장소 목록 — 연동 추가 폼이 열려 있을 땐 숨김 -->
|
||||
<div
|
||||
v-if="!showForm"
|
||||
class="list"
|
||||
>
|
||||
<div
|
||||
v-for="repo in repos"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
>
|
||||
<div class="repo-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span
|
||||
v-if="repo.giteaMissing"
|
||||
class="missing"
|
||||
>Gitea 연결 끊김</span>
|
||||
</div>
|
||||
<div class="repo-sub">
|
||||
<span class="slug">{{ repo.slug }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="branch">{{ repo.branch }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-actions">
|
||||
<a
|
||||
v-if="repo.htmlUrl"
|
||||
:href="repo.htmlUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn ghost sm"
|
||||
>
|
||||
Gitea 열기
|
||||
</a>
|
||||
<button
|
||||
v-if="canManage"
|
||||
class="btn danger sm"
|
||||
type="button"
|
||||
@click="unlinkRepo(repo)"
|
||||
>
|
||||
연동 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading && repos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="repos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
아직 연동된 저장소가 없습니다.{{ canManage ? ' ‘저장소 연동’으로 추가해 보세요.' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트를 찾지 못한 경우 -->
|
||||
<div
|
||||
v-else
|
||||
class="page"
|
||||
>
|
||||
<p class="not-found-msg">
|
||||
요청하신 프로젝트를 찾을 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.repos-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1.125rem 0 1rem;
|
||||
}
|
||||
/* 멤버 탭 가이드(.tab-note)와 동일한 형식 */
|
||||
.tab-note {
|
||||
flex: 1;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.tab-note svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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;
|
||||
gap: 0.375rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn.sm {
|
||||
height: 1.875rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.781rem;
|
||||
}
|
||||
.btn.primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.danger {
|
||||
border: 1px solid var(--red-border);
|
||||
background: #fff;
|
||||
color: var(--red);
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: var(--red-weak);
|
||||
}
|
||||
|
||||
/* 연동 폼 */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.form {
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
.fblock {
|
||||
padding: 1.125rem 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.4375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.5625rem;
|
||||
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);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
.fstatus {
|
||||
font-size: 0.781rem;
|
||||
margin-top: 0.4375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fstatus.ok {
|
||||
color: #1b7a3d;
|
||||
}
|
||||
.fstatus.bad {
|
||||
color: var(--red);
|
||||
}
|
||||
/* 템플릿 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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 저장소 목록 */
|
||||
.list {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.repo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.repo-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.repo-ico {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.repo-ico svg {
|
||||
width: 1.1875rem;
|
||||
height: 1.1875rem;
|
||||
}
|
||||
.repo-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.missing {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
.repo-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
margin-top: 0.3125rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.repo-sub .dot {
|
||||
font-family: inherit;
|
||||
}
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.list-empty {
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
.not-found-msg {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -112,7 +112,6 @@ async function removeRepo() {
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
:repo-count="repo.repoCount"
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
@@ -320,10 +319,6 @@ textarea.tarea {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gitea-bar-spacing {
|
||||
margin-top: 0.6875rem;
|
||||
}
|
||||
|
||||
.readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -63,12 +63,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/ProjectMembersPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/repos',
|
||||
name: 'project-repos',
|
||||
component: () => import('@/pages/relay/ProjectReposPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/activity',
|
||||
name: 'project-activity',
|
||||
|
||||
@@ -45,10 +45,6 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 표시 제목을 ‘${escapeHtml(pstr(p, 'newName'))}’(으)로 변경했습니다`,
|
||||
}
|
||||
case 'repo.linked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b>을(를) 연동했습니다` }
|
||||
case 'repo.unlinked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b> 연동을 해제했습니다` }
|
||||
case 'member.invited':
|
||||
return { ...base, tone: 'member', icon: 'member-add', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버로 초대했습니다` }
|
||||
case 'member.role_changed':
|
||||
|
||||
@@ -31,11 +31,9 @@ function toProjectView(api: ApiProject): ProjectView {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
desc: api.description ?? '',
|
||||
isSystem: api.isSystem,
|
||||
members: shown,
|
||||
moreCount: Math.max(0, api.memberCount - shown.length),
|
||||
updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`,
|
||||
repoCount: api.repoCount,
|
||||
doneCount: api.doneCount,
|
||||
totalCount: api.totalCount,
|
||||
progressPct: prog.pct,
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { ApiMember } from '@/types/repo'
|
||||
export type ApiActivityType =
|
||||
| 'project.created'
|
||||
| 'project.renamed'
|
||||
| 'repo.linked'
|
||||
| 'repo.unlinked'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
|
||||
@@ -7,10 +7,8 @@ export interface ApiProject {
|
||||
id: string // UUID (라우팅 식별자)
|
||||
name: string
|
||||
description: string | null
|
||||
isSystem: boolean // "미분류" 시스템 프로젝트
|
||||
members: ApiMember[]
|
||||
memberCount: number
|
||||
repoCount: number // 연동 저장소 수
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 저장소 멤버(API 원시) — PublicUser 와 동일
|
||||
// 공개 사용자(API 원시) — 백엔드 PublicUser 와 동일. 멤버/담당자/작성자 등에 공통 사용.
|
||||
export interface ApiMember {
|
||||
id: string
|
||||
email: string
|
||||
@@ -6,43 +6,3 @@ export interface ApiMember {
|
||||
role: string | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
// 저장소(API 원시) — 백엔드 RepoResponse 와 1:1. 프로젝트에 연동된 Gitea 저장소(코드 연동 전용).
|
||||
export interface ApiRepo {
|
||||
id: string // slugName (라우팅 식별자)
|
||||
projectId: string // 소속 프로젝트
|
||||
name: string
|
||||
owner: string
|
||||
slug: string // owner/slugName
|
||||
desc: string | null
|
||||
branch: string
|
||||
cloneUrl: string | null // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string
|
||||
// 현재 사용자 권한 — 단건 조회에서만 내려옴(목록은 미포함)
|
||||
canManage?: boolean // 프로젝트 admin (설정 변경)
|
||||
isOwner?: boolean // 프로젝트 소유자
|
||||
}
|
||||
|
||||
// 저장소 생성 요청
|
||||
export interface CreateRepoPayload {
|
||||
slug: string // 영문 저장소 이름
|
||||
name: string // 표시 제목(한글)
|
||||
description: string // 프로젝트 설명(필수)
|
||||
branch?: string
|
||||
useTemplate?: boolean // 템플릿 저장소 복제 여부
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 (소유자/템플릿)
|
||||
export interface RepoCreateMeta {
|
||||
owner: string
|
||||
template: { available: boolean; slug: string }
|
||||
}
|
||||
|
||||
// 저장소 수정 요청
|
||||
export interface UpdateRepoPayload {
|
||||
name?: string
|
||||
description?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user