first commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 홈 페이지 — 템플릿 기본 진입점
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref<string>('You did it!')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<h1>{{ message }}</h1>
|
||||
<p>
|
||||
Visit
|
||||
<a
|
||||
href="https://vuejs.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuejs.org</a>
|
||||
to read the documentation
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// 404 페이지 — 라우팅 매칭 실패 시 노출
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="not-found"
|
||||
role="alert"
|
||||
>
|
||||
<h1>404</h1>
|
||||
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
|
||||
<RouterLink to="/">
|
||||
홈으로 돌아가기
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,687 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 폼 상태
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const keepLogin = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 로그인 처리 — 성공 시 원래 목적지(redirect) 또는 저장소 목록으로 이동
|
||||
async function onLogin() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await authStore.login({
|
||||
email: email.value.trim(),
|
||||
password: password.value,
|
||||
keepLogin: keepLogin.value,
|
||||
})
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/repos'
|
||||
await router.push(redirect)
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 소셜 로그인 — 8단계에서 구현 예정
|
||||
function onSocial() {
|
||||
window.alert('소셜 로그인은 준비 중입니다. 이메일로 로그인해 주세요.')
|
||||
}
|
||||
|
||||
// 브랜드 패널 강조 포인트
|
||||
const points = [
|
||||
'저장소 · 업무 · 담당자로 이어지는 명확한 구조',
|
||||
'체크리스트와 마감기한으로 진행 관리',
|
||||
'Gitea 연동으로 안전하게',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<!-- 브랜드 패널 -->
|
||||
<aside class="brand-panel">
|
||||
<div class="blob b1" />
|
||||
<div class="blob b2" />
|
||||
<div class="ring" />
|
||||
<div class="bp-logo">
|
||||
<span class="logo">R</span> Relay
|
||||
</div>
|
||||
<div class="bp-body">
|
||||
<div class="bp-head">
|
||||
지시부터 완료까지,<br>팀의 업무 흐름을 하나로
|
||||
</div>
|
||||
<div class="bp-sub">
|
||||
저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
|
||||
추적됩니다.
|
||||
</div>
|
||||
<div class="bp-points">
|
||||
<div
|
||||
v-for="point in points"
|
||||
:key="point"
|
||||
class="bp-point"
|
||||
>
|
||||
<span class="ic">
|
||||
<svg
|
||||
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>
|
||||
</span>
|
||||
{{ point }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-foot">
|
||||
© 2026 Relay · 사내 업무 지시 플랫폼
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 폼 패널 -->
|
||||
<main class="form-panel">
|
||||
<div class="auth-card">
|
||||
<div class="ac-logo">
|
||||
<span class="logo">R</span> Relay
|
||||
</div>
|
||||
<h1 class="ac-title">
|
||||
로그인
|
||||
</h1>
|
||||
<p class="ac-sub">
|
||||
업무 지시를 시작하려면 로그인하세요.
|
||||
</p>
|
||||
|
||||
<!-- 소셜 로그인 -->
|
||||
<div class="socials">
|
||||
<button
|
||||
class="sbtn"
|
||||
type="button"
|
||||
@click="onSocial"
|
||||
>
|
||||
<span class="sico">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
fill="#FFC107"
|
||||
d="M43.6 20.5H42V20H24v8h11.3C33.7 32.4 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF3D00"
|
||||
d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"
|
||||
/>
|
||||
<path
|
||||
fill="#4CAF50"
|
||||
d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.5-11.2-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z"
|
||||
/>
|
||||
<path
|
||||
fill="#1976D2"
|
||||
d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C41.4 36 44 30.5 44 24c0-1.3-.1-2.3-.4-3.5z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
Google로 계속하기
|
||||
</button>
|
||||
<button
|
||||
class="sbtn kakao"
|
||||
type="button"
|
||||
@click="onSocial"
|
||||
>
|
||||
<span class="sico">
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#191600"
|
||||
>
|
||||
<path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.8 1.9 5.2 4.7 6.6-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.6.1 1.3.1 2 .1 5.5 0 10-3.5 10-7.8C22 6.5 17.5 3 12 3z" />
|
||||
</svg>
|
||||
</span>
|
||||
카카오로 계속하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider">
|
||||
또는 이메일로 로그인
|
||||
</div>
|
||||
|
||||
<!-- 이메일 로그인 -->
|
||||
<form @submit.prevent="onLogin">
|
||||
<div class="field">
|
||||
<label
|
||||
class="flabel"
|
||||
for="login-email"
|
||||
>이메일</label>
|
||||
<input
|
||||
id="login-email"
|
||||
v-model="email"
|
||||
class="tinput"
|
||||
type="email"
|
||||
placeholder="name@acme.co"
|
||||
autocomplete="email"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="flabel"
|
||||
for="login-pw"
|
||||
>비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
<input
|
||||
id="login-pw"
|
||||
v-model="password"
|
||||
class="tinput"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="비밀번호 입력"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
<button
|
||||
class="pw-toggle"
|
||||
type="button"
|
||||
:title="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
|
||||
:aria-label="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<button
|
||||
class="keep"
|
||||
type="button"
|
||||
@click="keepLogin = !keepLogin"
|
||||
>
|
||||
<span
|
||||
class="box"
|
||||
:class="{ on: keepLogin }"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
로그인 상태 유지
|
||||
</button>
|
||||
<a
|
||||
class="link"
|
||||
href="#"
|
||||
>비밀번호 찾기</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="submit"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? '로그인 중…' : '로그인' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="signup">
|
||||
계정이 없으신가요? <RouterLink to="/signup">
|
||||
회원가입
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="verify-note">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 6 12 13 2 6" />
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/>
|
||||
</svg>
|
||||
이메일 회원가입은 인증 메일 확인 후 완료됩니다
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 로그인 화면은 본문보다 큰 모서리 반경을 사용 */
|
||||
.auth {
|
||||
--radius: 8px;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- 브랜드 패널 ---- */
|
||||
.brand-panel {
|
||||
flex: 0 0 46%;
|
||||
max-width: 38.75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
|
||||
color: #fff;
|
||||
padding: 2.75rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-panel .blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-panel .b1 {
|
||||
width: 22.5rem;
|
||||
height: 22.5rem;
|
||||
right: -7.5rem;
|
||||
top: -5.625rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.brand-panel .b2 {
|
||||
width: 16.25rem;
|
||||
height: 16.25rem;
|
||||
left: -5.625rem;
|
||||
bottom: -4.375rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.brand-panel .ring {
|
||||
position: absolute;
|
||||
right: 3.75rem;
|
||||
bottom: 5rem;
|
||||
width: 11.25rem;
|
||||
height: 11.25rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand-panel .ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2.125rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: -0.0125rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-logo .logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.bp-body {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-head {
|
||||
font-size: 2.125rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.05rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.bp-sub {
|
||||
margin-top: 1.125rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
max-width: 25rem;
|
||||
}
|
||||
.bp-points {
|
||||
margin-top: 1.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.bp-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.bp-point .ic {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bp-point .ic svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.bp-foot {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- 폼 패널 ---- */
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.ac-logo {
|
||||
display: none;
|
||||
}
|
||||
.ac-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03125rem;
|
||||
}
|
||||
.ac-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.4375rem;
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
.sbtn {
|
||||
height: 2.875rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.906rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
.sbtn:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.sbtn .sico {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.sbtn.kakao {
|
||||
background: var(--kakao);
|
||||
border-color: var(--kakao);
|
||||
color: var(--kakao-text);
|
||||
}
|
||||
.sbtn.kakao:hover {
|
||||
background: #f2d900;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
margin: 1.375rem 0;
|
||||
color: var(--text-3);
|
||||
font-size: 0.781rem;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.4375rem;
|
||||
display: block;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8125rem;
|
||||
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);
|
||||
}
|
||||
.pw-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.pw-wrap .tinput {
|
||||
padding-right: 2.75rem;
|
||||
}
|
||||
.pw-toggle {
|
||||
position: absolute;
|
||||
right: 0.375rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.pw-toggle:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pw-toggle svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0.25rem 0 1.375rem;
|
||||
}
|
||||
.keep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.keep .box {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.keep .box.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.keep .box svg {
|
||||
width: 0.6875rem;
|
||||
height: 0.6875rem;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.keep .box.on svg {
|
||||
opacity: 1;
|
||||
}
|
||||
.link {
|
||||
color: var(--accent);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 2.875rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.signup {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.signup a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.signup a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.verify-note {
|
||||
margin-top: 0.375rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
.verify-note svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
|
||||
/* 좁은 화면 — 브랜드 패널 숨기고 카드 로고 노출 */
|
||||
@media (max-width: 55rem) {
|
||||
.brand-panel {
|
||||
display: none;
|
||||
}
|
||||
.ac-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.0625rem;
|
||||
margin-bottom: 1.625rem;
|
||||
}
|
||||
.ac-logo .logo {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,751 @@
|
||||
<script setup lang="ts">
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { MY_TASKS_ASSIGNED, MY_TASKS_ISSUED, countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
|
||||
type View = 'assigned' | 'issued'
|
||||
const view = ref<View>('assigned')
|
||||
|
||||
const REPO = 'q3-launch-campaign'
|
||||
|
||||
// 행 클릭 시 이동 경로
|
||||
// - 담당 뷰: 담당자 관점 업무 상세(상태별 분기)
|
||||
// - 지시 뷰: 지시자 관점 업무 상세(TaskDetailPage)
|
||||
function taskLink(row: MyTaskRow): string {
|
||||
if (view.value === 'issued') return `/repos/${REPO}/tasks/${row.id}`
|
||||
const state = row.status === 'changes' ? 'changes' : row.status === 'done' ? 'approved' : 'request'
|
||||
return `/repos/${REPO}/tasks/${row.id}/view?state=${state}`
|
||||
}
|
||||
|
||||
const groups = computed(() => (view.value === 'assigned' ? MY_TASKS_ASSIGNED : MY_TASKS_ISSUED))
|
||||
|
||||
const assignedCount = countActive(MY_TASKS_ASSIGNED)
|
||||
const issuedCount = countActive(MY_TASKS_ISSUED)
|
||||
// 지시 뷰의 승인 대기(내 승인 필요) 건수
|
||||
const issuedReviewCount = computed(
|
||||
() => MY_TASKS_ISSUED.find((g) => g.key === 'review')?.rows.length ?? 0,
|
||||
)
|
||||
// 승인 대기 강조 배너는 지시 뷰에서 표시
|
||||
const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCount.value > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="pagehead">
|
||||
<h1>내 업무</h1>
|
||||
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
|
||||
<RouterLink
|
||||
class="new-btn"
|
||||
to="/repos/q3-launch-campaign/tasks/new"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
새 업무 지시
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 담당 / 지시 토글 -->
|
||||
<div class="viewtabs">
|
||||
<button
|
||||
class="viewtab"
|
||||
:class="{ active: view === 'assigned' }"
|
||||
type="button"
|
||||
@click="view = 'assigned'"
|
||||
>
|
||||
<span class="tb-label">담당 업무</span>
|
||||
<span class="vt-count">{{ assignedCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="viewtab"
|
||||
:class="{ active: view === 'issued' }"
|
||||
type="button"
|
||||
@click="view = 'issued'"
|
||||
>
|
||||
<span class="tb-label">지시한 업무</span>
|
||||
<span class="vt-count">{{ issuedCount }}</span>
|
||||
<span
|
||||
v-if="issuedReviewCount > 0"
|
||||
class="vt-flag"
|
||||
>승인 대기 {{ issuedReviewCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/><path d="m21 21-4-4" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="업무 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="repofilter">
|
||||
<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>
|
||||
모든 저장소
|
||||
<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>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 강조 배너 (지시 뷰) -->
|
||||
<div
|
||||
v-if="showPendingNote"
|
||||
class="pending-note"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 8v4l3 2" /><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
</svg>
|
||||
<span><b>{{ issuedReviewCount }}건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- 상태 그룹 -->
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
class="group"
|
||||
>
|
||||
<div class="group-head">
|
||||
<span
|
||||
class="gh-dot"
|
||||
:class="group.key"
|
||||
/>
|
||||
<span class="gh-label">{{ group.label }}</span>
|
||||
<span class="gh-count">{{ group.rows.length }}</span>
|
||||
<span
|
||||
v-if="group.action"
|
||||
class="gh-action"
|
||||
>{{ group.action }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="list"
|
||||
:class="{ attn: group.attn }"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="row in group.rows"
|
||||
:key="row.id"
|
||||
class="task-row"
|
||||
:class="{ 'is-done': row.done }"
|
||||
:to="taskLink(row)"
|
||||
>
|
||||
<span
|
||||
class="st-dot"
|
||||
:class="row.status"
|
||||
/>
|
||||
<span class="task-main">
|
||||
<span class="task-title">
|
||||
<span class="ttext">{{ row.title }}</span>
|
||||
<span class="tid">#{{ row.id }}</span>
|
||||
</span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip">
|
||||
<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>
|
||||
{{ row.repoName }}
|
||||
</span>
|
||||
<span class="meta-sep" />
|
||||
<span
|
||||
v-if="row.checklist"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
{{ row.checklist[0] }}/{{ row.checklist[1] }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.checklist"
|
||||
class="meta-sep"
|
||||
/>
|
||||
<!-- 지시 뷰: 담당자 아바타 + (강조 아닌) 보조 텍스트 -->
|
||||
<span
|
||||
v-if="row.assignees && !row.metaAttn"
|
||||
class="mi"
|
||||
>
|
||||
<span class="mini-stack">
|
||||
<span
|
||||
v-for="a in row.assignees"
|
||||
:key="a.id"
|
||||
class="mini-av"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
</span>
|
||||
{{ row.metaText }}
|
||||
</span>
|
||||
<!-- 지시 뷰 승인대기: 아바타와 강조 텍스트를 분리 표기 -->
|
||||
<template v-else-if="row.assignees && row.metaAttn">
|
||||
<span class="mi">
|
||||
<span class="mini-stack">
|
||||
<span
|
||||
v-for="a in row.assignees"
|
||||
:key="a.id"
|
||||
class="mini-av"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="meta-sep" />
|
||||
<span class="mi attn">{{ row.metaText }}</span>
|
||||
</template>
|
||||
<!-- 담당 뷰: 보조 텍스트만 -->
|
||||
<span
|
||||
v-else-if="row.metaText"
|
||||
class="mi"
|
||||
>{{ row.metaText }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span
|
||||
class="due"
|
||||
:class="{ overdue: row.overdue }"
|
||||
>
|
||||
<template v-if="row.dday">
|
||||
<svg
|
||||
v-if="row.overdue"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/><path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
{{ row.dueLabel }} <span
|
||||
class="dday"
|
||||
:class="row.ddayLevel"
|
||||
>{{ row.dday }}</span>
|
||||
</template>
|
||||
<template v-else>{{ row.dueLabel }}</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="row.reviewBtn"
|
||||
class="review-btn"
|
||||
>검토하기</span>
|
||||
<span
|
||||
v-else-if="row.statusLabel"
|
||||
class="st-badge"
|
||||
:class="row.status"
|
||||
>{{ row.statusLabel }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 0 1rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagehead .sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
padding-bottom: 0.125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn {
|
||||
margin-left: auto;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.9375rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.new-btn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 담당/지시 토글 */
|
||||
.viewtabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
.viewtab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.25rem;
|
||||
margin-right: 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.viewtab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.viewtab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.viewtab .vt-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border-radius: 20px;
|
||||
height: 1.0625rem;
|
||||
min-width: 1.0625rem;
|
||||
padding: 0 0.375rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.viewtab.active .vt-count {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.viewtab .vt-flag {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
border: 1px solid var(--amber-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar .search {
|
||||
width: 16.25rem;
|
||||
}
|
||||
.repofilter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.6875rem;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repofilter svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 승인 대기 강조 배너 */
|
||||
.pending-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.6875rem 0.9375rem;
|
||||
background: var(--amber-weak);
|
||||
border: 1px solid var(--amber-border);
|
||||
border-radius: 0.5625rem;
|
||||
margin-bottom: 1.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #8a5a12;
|
||||
}
|
||||
.pending-note svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pending-note b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 상태 그룹 */
|
||||
.group {
|
||||
margin-bottom: 1.375rem;
|
||||
}
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
margin-bottom: 0.5625rem;
|
||||
padding-left: 0.125rem;
|
||||
}
|
||||
.gh-dot {
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gh-dot.changes {
|
||||
background: var(--red);
|
||||
}
|
||||
.gh-dot.prog {
|
||||
background: var(--blue);
|
||||
}
|
||||
.gh-dot.todo {
|
||||
background: #c4c8cf;
|
||||
}
|
||||
.gh-dot.review {
|
||||
background: var(--amber);
|
||||
}
|
||||
.gh-dot.done {
|
||||
background: var(--green);
|
||||
}
|
||||
.gh-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gh-count {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #eceef1;
|
||||
border-radius: 20px;
|
||||
height: 1.125rem;
|
||||
min-width: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.gh-action {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--amber);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list.attn {
|
||||
border-color: var(--amber-border);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(183, 121, 31, 0.08),
|
||||
var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* 업무 행 */
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.8125rem 1.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.task-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.task-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.task-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.task-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.task-title .ttext {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.task-title .tid {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-row:hover .task-title .ttext {
|
||||
color: var(--accent);
|
||||
}
|
||||
.task-row.is-done .task-title {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.repo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo-chip svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mi {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mi svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.mi.attn {
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
.meta-sep {
|
||||
width: 0.1875rem;
|
||||
height: 0.1875rem;
|
||||
border-radius: 50%;
|
||||
background: #d7dae0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 담당자 미니 아바타 스택 */
|
||||
.mini-stack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.mini-av {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1.5px #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-av.av-a {
|
||||
background: #e0518d;
|
||||
}
|
||||
.mini-av.av-b {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.mini-av.av-c {
|
||||
background: #16a37b;
|
||||
}
|
||||
.mini-av.av-d {
|
||||
background: #b3631f;
|
||||
}
|
||||
.mini-av.av-e {
|
||||
background: #7c5cf0;
|
||||
}
|
||||
.mini-av.av-f {
|
||||
background: #d0982a;
|
||||
}
|
||||
.mini-stack .mini-av + .mini-av {
|
||||
margin-left: -0.3125rem;
|
||||
}
|
||||
|
||||
.task-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.due svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.due.overdue {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.due.overdue svg {
|
||||
color: var(--red);
|
||||
}
|
||||
.dday {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dday.urgent {
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
}
|
||||
.dday.soon {
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
border: 1px solid var(--amber-border);
|
||||
}
|
||||
.dday.calm {
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.task-right .st-badge {
|
||||
min-width: 4rem;
|
||||
}
|
||||
.review-btn {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--amber);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.3125rem 0.6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 활동 — 날짜 그룹 단위 활동 피드
|
||||
import { computed, defineComponent, h, ref } 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 { REPO_ACTIVITY, findRepo, findRepoMembers, type ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
const headerMembers = computed(() => findRepoMembers(repoId.value).map((m) => m.user))
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
// 필터 적용 후 빈 날짜 그룹은 제거
|
||||
const days = computed(() =>
|
||||
REPO_ACTIVITY.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
})).filter((d) => d.items.length > 0),
|
||||
)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
|
||||
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
|
||||
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
repo: '<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"/>',
|
||||
}
|
||||
const ActIcon = defineComponent({
|
||||
props: { name: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="repo"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>활동</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
meta-text="2시간 전 업데이트"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="activity"
|
||||
:task-count="12"
|
||||
:member-count="headerMembers.length"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'task' }"
|
||||
@click="filter = 'task'"
|
||||
>
|
||||
업무
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'member' }"
|
||||
@click="filter = 'member'"
|
||||
>
|
||||
멤버
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'setting' }"
|
||||
@click="filter = 'setting'"
|
||||
>
|
||||
설정
|
||||
</button>
|
||||
</div>
|
||||
<div class="mfilter">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="12"
|
||||
cy="7"
|
||||
r="4"
|
||||
/>
|
||||
</svg>
|
||||
모든 멤버
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<!-- act-text 의 v-html 은 내부에서 생성한 신뢰 가능한 활동 마크업(목업)만 렌더한다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="feed">
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
:key="group.day"
|
||||
>
|
||||
<div class="day">
|
||||
{{ group.day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, i) in group.items"
|
||||
:key="group.day + i"
|
||||
class="act"
|
||||
:class="{ 'last-in-group': i === group.items.length - 1 }"
|
||||
>
|
||||
<span
|
||||
class="act-ico"
|
||||
:class="item.tone"
|
||||
><ActIcon :name="item.icon" /></span>
|
||||
<div class="act-main">
|
||||
<span
|
||||
class="act-av"
|
||||
:class="item.color"
|
||||
>{{ item.initial }}</span>
|
||||
<span
|
||||
class="act-text"
|
||||
v-html="item.html"
|
||||
/>
|
||||
</div>
|
||||
<span class="act-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.mfilter {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.6875rem;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mfilter svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 활동 피드 */
|
||||
.feed {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.375rem 1.25rem 1.125rem;
|
||||
}
|
||||
.feed-inner {
|
||||
position: relative;
|
||||
}
|
||||
.day {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0125rem;
|
||||
padding: 1.125rem 0 0.375rem;
|
||||
}
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.5625rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.act::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.969rem;
|
||||
top: 50%;
|
||||
bottom: -0.5625rem;
|
||||
width: 1.5px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
.act.last-in-group::before {
|
||||
display: none;
|
||||
}
|
||||
.act-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.act-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.act-ico.task {
|
||||
background: var(--blue-weak);
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-ico.member {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.act-ico.setting {
|
||||
background: var(--amber-weak);
|
||||
color: var(--amber);
|
||||
}
|
||||
.act-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.act-av {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.act-av.av-a {
|
||||
background: #e0518d;
|
||||
}
|
||||
.act-av.av-b {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.act-av.av-c {
|
||||
background: #16a37b;
|
||||
}
|
||||
.act-av.av-d {
|
||||
background: #b3631f;
|
||||
}
|
||||
.act-av.av-e {
|
||||
background: #7c5cf0;
|
||||
}
|
||||
.act-av.av-f {
|
||||
background: #d0982a;
|
||||
}
|
||||
.act-text {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.act-text :deep(b) {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.tgt) {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-text :deep(.tgt:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act-text :deep(.st) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.st.prog) {
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-text :deep(.st.done) {
|
||||
color: var(--green);
|
||||
}
|
||||
.act-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,760 @@
|
||||
<script setup lang="ts">
|
||||
// 새 저장소 만들기 — 저장소 생성 폼
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const repoStore = useRepoStore()
|
||||
|
||||
// 폼 상태
|
||||
const repoName = ref('')
|
||||
const displayTitle = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const description = ref('')
|
||||
const template = ref('없음 (빈 저장소)')
|
||||
const branch = ref('main')
|
||||
const submitting = ref(false)
|
||||
|
||||
const templates = ['없음 (빈 저장소)', '기본 업무 템플릿', '디자인 프로젝트 템플릿', '개발 프로젝트 템플릿']
|
||||
|
||||
// 생성 — 성공 시 새 저장소 상세로 이동
|
||||
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
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const repo = await repoStore.create({
|
||||
slug: repoName.value.trim(),
|
||||
name: displayTitle.value.trim(),
|
||||
visibility: visibility.value,
|
||||
description: description.value.trim() || undefined,
|
||||
branch: branch.value.trim() || undefined,
|
||||
})
|
||||
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="소유자는 변경할 수 없습니다"
|
||||
>
|
||||
<span class="owner-avatar">M</span>
|
||||
marketing-team
|
||||
<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 class="avail">
|
||||
<svg
|
||||
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>
|
||||
사용 가능한 이름입니다
|
||||
</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">
|
||||
프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소 목록과 상단에 표시됩니다. (선택)
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 템플릿 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
템플릿
|
||||
</div>
|
||||
<div class="fhint">
|
||||
선택한 템플릿의 폴더 구조와 기본 업무가 새 저장소에 복제됩니다.
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select v-model="template">
|
||||
<option
|
||||
v-for="t in templates"
|
||||
:key="t"
|
||||
>
|
||||
{{ t }}
|
||||
</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;
|
||||
}
|
||||
.owner-avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #0ea5a3;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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;
|
||||
color: var(--green);
|
||||
margin-top: 0.5625rem;
|
||||
}
|
||||
.avail svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 상세 — 저장소 헤더(공통) + 진행률 + 서브탭(공통) + 업무 목록
|
||||
import { computed, 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 { useRepoStore } from '@/stores/repo.store'
|
||||
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
type Segment = 'all' | 'prog' | 'todo' | 'done'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
|
||||
// 저장소 헤더/진행률 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, loadRepo, { immediate: true })
|
||||
|
||||
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
|
||||
const tasks = computed(() => findRepoTasks(repoId.value))
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<Segment>('all')
|
||||
|
||||
// 세그먼트별 분류 규칙
|
||||
function inSegment(status: TaskStatus, seg: Segment): boolean {
|
||||
if (seg === 'all') return true
|
||||
if (seg === 'prog') return status === 'prog' || status === 'review'
|
||||
if (seg === 'todo') return status === 'todo'
|
||||
return status === 'done'
|
||||
}
|
||||
|
||||
const counts = computed(() => ({
|
||||
all: tasks.value.length,
|
||||
prog: tasks.value.filter((t) => inSegment(t.status, 'prog')).length,
|
||||
todo: tasks.value.filter((t) => inSegment(t.status, 'todo')).length,
|
||||
done: tasks.value.filter((t) => inSegment(t.status, 'done')).length,
|
||||
}))
|
||||
|
||||
const filteredTasks = computed(() =>
|
||||
tasks.value.filter(
|
||||
(t) =>
|
||||
inSegment(t.status, segment.value) && (!keyword.value || t.title.includes(keyword.value)),
|
||||
),
|
||||
)
|
||||
|
||||
// 지연 건수
|
||||
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
|
||||
// 업무 상태 → 배지 라벨
|
||||
const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
prog: '진행 중',
|
||||
review: '승인 대기',
|
||||
todo: '대기',
|
||||
done: '완료',
|
||||
changes: '수정 요청',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="repo"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>{{ repo.name }}</b>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더(공통) — 진행률 스트립을 extra 슬롯으로 주입 -->
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
:meta-text="repo.updatedAgo"
|
||||
>
|
||||
<template #extra>
|
||||
<div class="prog-strip">
|
||||
<span class="prog-txt">{{ repo.doneCount }} <span>/ {{ repo.totalCount }} 업무 완료</span> · {{ repo.progressPct }}%</span>
|
||||
<div class="pbar">
|
||||
<i :style="{ width: repo.progressPct + '%' }" />
|
||||
</div>
|
||||
<span
|
||||
v-if="overdueCount > 0"
|
||||
class="prog-warn"
|
||||
>
|
||||
<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="9"
|
||||
/><path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
지연 {{ overdueCount }}건
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</RepoHeader>
|
||||
|
||||
<!-- 서브탭(공통) -->
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="tasks"
|
||||
:task-count="counts.all"
|
||||
:member-count="memberCount"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/><path d="m21 21-4-4" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="업무 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: segment === 'all' }"
|
||||
@click="segment = 'all'"
|
||||
>
|
||||
전체 <span class="n">{{ counts.all }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'prog' }"
|
||||
@click="segment = 'prog'"
|
||||
>
|
||||
진행 중 <span class="n">{{ counts.prog }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'todo' }"
|
||||
@click="segment = 'todo'"
|
||||
>
|
||||
대기 <span class="n">{{ counts.todo }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'done' }"
|
||||
@click="segment = 'done'"
|
||||
>
|
||||
완료 <span class="n">{{ counts.done }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업무 목록 -->
|
||||
<div class="list">
|
||||
<RouterLink
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ 'is-done': task.status === 'done' }"
|
||||
:to="`/repos/${repo.id}/tasks/${task.id}`"
|
||||
>
|
||||
<span
|
||||
class="st-dot"
|
||||
:class="task.status"
|
||||
/>
|
||||
<span class="task-main">
|
||||
<span class="task-title">{{ task.title }} <span class="tid">#{{ task.id }}</span></span>
|
||||
<span class="task-meta">
|
||||
<span
|
||||
v-if="task.overdue"
|
||||
class="mi overdue"
|
||||
>
|
||||
<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="9"
|
||||
/><path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
{{ task.dueLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="task.status !== 'done' && task.dueLabel"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
{{ task.dueLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.checklist"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
{{ task.checklist[0] }}/{{ task.checklist[1] }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.comments"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
{{ task.comments }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status === 'done'"
|
||||
class="mi"
|
||||
>{{ task.dueLabel }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack">
|
||||
<span
|
||||
v-for="a in task.assignees"
|
||||
:key="a.id"
|
||||
class="avatar"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="st-badge"
|
||||
:class="task.status"
|
||||
>{{ STATUS_LABEL[task.status] }}</span>
|
||||
<span class="chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.9375rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.prog-strip .pbar {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
max-width: 22.5rem;
|
||||
}
|
||||
.prog-txt {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prog-txt span {
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.prog-warn {
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prog-warn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.toolbar .search {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
/* 업무 행 */
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.task-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.task-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.task-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.task-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.task-title .tid {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.task-row.is-done .task-title {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8125rem;
|
||||
margin-top: 0.1875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.task-meta .mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-meta .mi svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.task-meta .mi.overdue {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-right .st-badge {
|
||||
min-width: 3.5rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.task-right .st-badge.done {
|
||||
border-color: var(--green-border);
|
||||
}
|
||||
.task-right .st-badge.prog {
|
||||
border-color: var(--blue-border);
|
||||
}
|
||||
.task-right .st-badge.review {
|
||||
border-color: var(--amber-border);
|
||||
}
|
||||
.task-right .st-badge.todo {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.chev {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chev svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.not-found-msg {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,470 @@
|
||||
<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 { useRepoStore } from '@/stores/repo.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | Visibility
|
||||
|
||||
const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading } = storeToRefs(repoStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
})
|
||||
|
||||
// 검색어 + 공개여부 필터 적용
|
||||
const filteredRepos = computed(() =>
|
||||
repos.value.filter((repo) => {
|
||||
const matchKeyword =
|
||||
!keyword.value ||
|
||||
repo.name.includes(keyword.value) ||
|
||||
repo.slug.includes(keyword.value)
|
||||
const matchFilter = filter.value === 'all' || repo.visibility === filter.value
|
||||
return matchKeyword && matchFilter
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<b>저장소</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<span class="count-pill">{{ repos.length }}</span>
|
||||
<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">
|
||||
<b>marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
|
||||
전달하세요.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/>
|
||||
<path d="m21 21-4-4" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="저장소 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'private' }"
|
||||
@click="filter = 'private'"
|
||||
>
|
||||
비공개
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'public' }"
|
||||
@click="filter = 'public'"
|
||||
>
|
||||
공개
|
||||
</button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
최근 업데이트순
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
to="/repos/new"
|
||||
>
|
||||
<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>
|
||||
새 저장소
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="list">
|
||||
<RouterLink
|
||||
v-for="repo in filteredRepos"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
:to="`/repos/${repo.id}`"
|
||||
>
|
||||
<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 class="repo-slug">{{ repo.slug }}</span>
|
||||
<span
|
||||
class="vis"
|
||||
:class="repo.visibility"
|
||||
>
|
||||
<svg
|
||||
v-if="repo.visibility === 'private'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.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>
|
||||
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="repo-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi">
|
||||
<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>
|
||||
{{ repo.branch }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
v-for="m in repo.members"
|
||||
:key="m.id"
|
||||
class="avatar"
|
||||
:class="m.color"
|
||||
>{{ m.initial }}</span>
|
||||
<span
|
||||
v-if="repo.moreCount > 0"
|
||||
class="avatar av-more"
|
||||
>+{{ repo.moreCount }}</span>
|
||||
</span>
|
||||
<span class="mi">{{ repo.updatedAgo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top">
|
||||
<b>{{ repo.doneCount }}</b> / {{ repo.totalCount }} 업무
|
||||
</div>
|
||||
<div class="pbar">
|
||||
<i
|
||||
:class="repo.progressLevel === 'mid' ? 'mid' : repo.progressLevel === 'low' ? 'low' : ''"
|
||||
:style="{ width: repo.progressPct + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="stat-pct">
|
||||
{{ repo.progressLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading && repos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredRepos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
padding: 0.5625rem 0 0.25rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
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;
|
||||
margin: 0.25rem 0 1.125rem;
|
||||
}
|
||||
.lede b {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* 저장소 행 */
|
||||
.repo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.repo-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.repo-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.repo-ico {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.repo-ico svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.repo-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.5625rem;
|
||||
row-gap: 0.125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.repo-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo-slug {
|
||||
order: 2;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.repo-row:hover .repo-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.repo-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 30rem;
|
||||
}
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9375rem;
|
||||
margin-top: 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.repo-meta .mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo-meta .mi svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.repo-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
width: 10.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-top {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stat-top b {
|
||||
color: var(--text);
|
||||
}
|
||||
.repo-stat .pbar {
|
||||
width: 9.75rem;
|
||||
}
|
||||
.stat-pct {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chev {
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chev svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.list-empty {
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,841 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달
|
||||
import { computed, onMounted, onUnmounted, ref } 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 { findRepo, findRepoMembers } from '@/mock/relay.mock'
|
||||
|
||||
type RoleFilter = 'all' | 'admin' | 'member'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
const members = computed(() => findRepoMembers(repoId.value))
|
||||
|
||||
// 헤더 아바타용 멤버(전체 로스터)
|
||||
const headerMembers = computed(() => members.value.map((m) => m.user))
|
||||
|
||||
const keyword = ref('')
|
||||
const filter = ref<RoleFilter>('all')
|
||||
|
||||
const counts = computed(() => ({
|
||||
all: members.value.length,
|
||||
admin: members.value.filter((m) => m.roleType === 'admin').length,
|
||||
member: members.value.filter((m) => m.roleType === 'member').length,
|
||||
}))
|
||||
|
||||
const filteredMembers = computed(() =>
|
||||
members.value.filter((m) => {
|
||||
const matchRole = filter.value === 'all' || m.roleType === filter.value
|
||||
const kw = keyword.value.trim()
|
||||
const matchKeyword = !kw || m.user.name.includes(kw) || m.email.includes(kw)
|
||||
return matchRole && matchKeyword
|
||||
}),
|
||||
)
|
||||
|
||||
// 초대 모달
|
||||
const showInvite = ref(false)
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') showInvite.value = false
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="repo"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>멤버</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
meta-text="멤버 5명"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="members"
|
||||
:task-count="12"
|
||||
:member-count="counts.all"
|
||||
/>
|
||||
|
||||
<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>
|
||||
이 저장소의 멤버만 업무 담당자로 지정할 수 있습니다. <b>관리자</b>는 업무 지시·멤버 관리가 가능하고, <b>멤버</b>는 담당자로 배정되어 업무를 수행합니다.
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/><path d="m21 21-4-4" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="이름·이메일 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
전체 <span class="n">{{ counts.all }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'admin' }"
|
||||
@click="filter = 'admin'"
|
||||
>
|
||||
관리자 <span class="n">{{ counts.admin }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'member' }"
|
||||
@click="filter = 'member'"
|
||||
>
|
||||
멤버 <span class="n">{{ counts.member }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn primary invite"
|
||||
type="button"
|
||||
@click="showInvite = true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/><path d="M19 8v6M22 11h-6" />
|
||||
</svg>
|
||||
멤버 초대
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 목록 -->
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="m in filteredMembers"
|
||||
:key="m.user.id"
|
||||
class="mem-row"
|
||||
>
|
||||
<span
|
||||
class="mem-av"
|
||||
:class="m.user.color"
|
||||
>{{ m.user.initial }}</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">
|
||||
{{ m.user.name }} <span
|
||||
v-if="m.isYou"
|
||||
class="you-pill"
|
||||
>나</span>
|
||||
</div>
|
||||
<div class="mem-sub">
|
||||
{{ m.email }} <span class="dot">·</span> {{ m.subRole }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mem-tasks"
|
||||
:class="{ zero: m.taskCount === 0 }"
|
||||
>
|
||||
<b>{{ m.taskCount }}</b>건 담당
|
||||
</div>
|
||||
<div
|
||||
v-if="m.owner"
|
||||
class="role fixed admin"
|
||||
>
|
||||
<span class="role-dot" />관리자
|
||||
<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>
|
||||
<button
|
||||
v-else
|
||||
class="role"
|
||||
type="button"
|
||||
>
|
||||
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
class="mem-remove"
|
||||
:class="{ disabled: m.owner }"
|
||||
type="button"
|
||||
:title="m.owner ? '소유자는 제거할 수 없습니다' : '멤버 제거'"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 초대 모달 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
:class="{ open: showInvite }"
|
||||
@click.self="showInvite = false"
|
||||
>
|
||||
<div
|
||||
class="invite-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2>멤버 초대</h2>
|
||||
<div class="mh-sub">
|
||||
<b>{{ repo?.name }}</b> 저장소에 멤버를 추가합니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="modal-close"
|
||||
type="button"
|
||||
@click="showInvite = false"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="m-label">초대할 사람</label>
|
||||
<div class="icombo">
|
||||
<div class="icombo-field">
|
||||
<span class="ichip"><span class="avatar av-f mini">한</span>한지민 <span class="x">×</span></span>
|
||||
<span class="ichip ext">
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" /></svg>
|
||||
partner@vendor.com <span class="x">×</span>
|
||||
</span>
|
||||
<input
|
||||
class="icombo-input"
|
||||
placeholder="이름 또는 이메일 입력…"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-row">
|
||||
<div class="m-role">
|
||||
<label class="m-label">역할</label>
|
||||
<div class="select-wrap">
|
||||
<select>
|
||||
<option>멤버</option>
|
||||
<option>관리자</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="m-grow">
|
||||
<label class="m-label">초대 메시지 <span class="muted">(선택)</span></label>
|
||||
<input
|
||||
class="m-input"
|
||||
type="text"
|
||||
placeholder="함께 캠페인 준비해요!"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-hint">
|
||||
<b>멤버</b>는 담당자로 배정되어 업무를 수행하고, <b>관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
|
||||
<button
|
||||
class="mbtn"
|
||||
type="button"
|
||||
@click="showInvite = false"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
type="button"
|
||||
@click="showInvite = false"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
초대 보내기 (2)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.tab-note {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin: 0.75rem 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.tab-note svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-note b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar .search {
|
||||
width: 15rem;
|
||||
}
|
||||
.invite {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 멤버 행 */
|
||||
.mem-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mem-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.mem-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mem-av {
|
||||
width: 2.375rem;
|
||||
height: 2.375rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mem-av.av-a {
|
||||
background: #e0518d;
|
||||
}
|
||||
.mem-av.av-b {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.mem-av.av-c {
|
||||
background: #16a37b;
|
||||
}
|
||||
.mem-av.av-d {
|
||||
background: #b3631f;
|
||||
}
|
||||
.mem-av.av-e {
|
||||
background: #7c5cf0;
|
||||
}
|
||||
.mem-av.av-f {
|
||||
background: #d0982a;
|
||||
}
|
||||
.mem-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mem-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.you-pill {
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mem-sub {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.mem-sub .dot {
|
||||
margin: 0 0.375rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.mem-tasks {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
width: 5.375rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mem-tasks b {
|
||||
color: var(--text);
|
||||
}
|
||||
.mem-tasks.zero {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 역할 컨트롤 */
|
||||
.role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.6875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: 6.5rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.role:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.role .role-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.role.admin {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.role.admin .role-dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.role .caret {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.role .caret svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.role.fixed {
|
||||
background: #f1f2f4;
|
||||
cursor: not-allowed;
|
||||
color: var(--text-2);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.role.fixed .role-dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.role.fixed .lock {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.role.fixed .lock svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
|
||||
.mem-remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.mem-row:hover .mem-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
.mem-remove:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--red);
|
||||
}
|
||||
.mem-remove svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.mem-remove.disabled {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 초대 모달은 Teleport 로 body 에 렌더되므로 비-scoped 전역 스타일 -->
|
||||
<style>
|
||||
.invite-modal {
|
||||
width: 100%;
|
||||
max-width: 32.75rem;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
.invite-modal .modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1.375rem 1.5rem 1rem;
|
||||
}
|
||||
.invite-modal .modal-head h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
}
|
||||
.invite-modal .mh-sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.invite-modal .mh-sub b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.invite-modal .modal-close {
|
||||
margin-left: auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invite-modal .modal-close:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-modal .modal-close svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.invite-modal .modal-body {
|
||||
padding: 0.25rem 1.5rem 0.5rem;
|
||||
}
|
||||
.invite-modal .m-label {
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
.invite-modal .m-label .muted {
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.invite-modal .icombo {
|
||||
position: relative;
|
||||
}
|
||||
.invite-modal .icombo-field {
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4375rem 0.5rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.invite-modal .ichip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 0.1875rem 0.5rem 0.1875rem 0.25rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invite-modal .ichip .avatar.mini {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
.invite-modal .ichip.ext {
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.invite-modal .ichip .x {
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.invite-modal .ichip .x:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.invite-modal .icombo-input {
|
||||
flex: 1;
|
||||
min-width: 7.5rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
background: transparent;
|
||||
}
|
||||
.invite-modal .icombo-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.invite-modal .m-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.125rem;
|
||||
}
|
||||
.invite-modal .m-role {
|
||||
flex: 0 0 9.375rem;
|
||||
}
|
||||
.invite-modal .m-grow {
|
||||
flex: 1;
|
||||
}
|
||||
.invite-modal .select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.invite-modal .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.125rem 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.invite-modal .select-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.invite-modal .select-wrap .caret {
|
||||
position: absolute;
|
||||
right: 0.6875rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.invite-modal .select-wrap .caret svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.invite-modal .m-input {
|
||||
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.844rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.invite-modal .m-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.invite-modal .m-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.invite-modal .role-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invite-modal .role-hint b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.invite-modal .modal-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.invite-modal .modal-foot .fnote {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-right: auto;
|
||||
}
|
||||
.invite-modal .mbtn {
|
||||
height: 2.375rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.invite-modal .mbtn:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-modal .mbtn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.invite-modal .mbtn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.invite-modal .mbtn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,679 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 설정 — 일반 설정 + 위험 구역 (관리자 전용)
|
||||
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 { useRepoStore } from '@/stores/repo.store'
|
||||
import { type Repo, type Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
|
||||
const repo = ref<Repo | null>(null)
|
||||
const headerMembers = computed(() => repo.value?.members ?? [])
|
||||
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)
|
||||
} catch {
|
||||
repo.value = null
|
||||
return
|
||||
}
|
||||
if (!repo.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 })
|
||||
|
||||
// 변경 저장
|
||||
async function save() {
|
||||
if (saving.value || !repo.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
repo.value = await repoStore.update(repoId.value, {
|
||||
name: displayTitle.value.trim(),
|
||||
description: description.value.trim(),
|
||||
visibility: visibility.value,
|
||||
branch: branch.value.trim() || 'main',
|
||||
})
|
||||
window.alert('변경 사항을 저장했습니다.')
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
async function removeRepo() {
|
||||
if (!repo.value) return
|
||||
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
|
||||
try {
|
||||
await repoStore.remove(repoId.value)
|
||||
await router.push('/repos')
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 이름(slug) 변경 — 8단계 이후 지원 예정
|
||||
function renameRepo() {
|
||||
window.alert('저장소 이름(slug) 변경은 준비 중입니다.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
v-if="repo"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<b>설정</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
:meta-text="`멤버 ${memberCount}명`"
|
||||
:members="headerMembers"
|
||||
:more-count="repo.moreCount"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
/>
|
||||
|
||||
<div class="settings">
|
||||
<div class="admin-note">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
저장소 설정은 <b>관리자</b>만 변경할 수 있습니다.
|
||||
</div>
|
||||
|
||||
<!-- 일반 -->
|
||||
<h2 class="sec-title">
|
||||
일반
|
||||
</h2>
|
||||
<div class="card">
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
표시 제목
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Relay 목록과 화면에 표시되는 이름입니다. (한글 가능)
|
||||
</div>
|
||||
<input
|
||||
v-model="displayTitle"
|
||||
class="tinput"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span>
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.
|
||||
</div>
|
||||
<div class="readonly">
|
||||
<span class="owner-avatar">M</span> {{ 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>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
공개 범위
|
||||
</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">
|
||||
메인 브랜치
|
||||
</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"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? '저장 중…' : '변경 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 위험 구역 -->
|
||||
<h2 class="sec-title danger">
|
||||
위험 구역
|
||||
</h2>
|
||||
<div class="danger-card">
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">
|
||||
Gitea 저장소 이름 변경
|
||||
</div>
|
||||
<div class="dr-desc">
|
||||
영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
@click="renameRepo"
|
||||
>
|
||||
이름 변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">
|
||||
저장소 삭제
|
||||
</div>
|
||||
<div class="dr-desc">
|
||||
저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-danger solid"
|
||||
type="button"
|
||||
@click="removeRepo"
|
||||
>
|
||||
저장소 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.settings {
|
||||
max-width: 45rem;
|
||||
}
|
||||
.admin-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
.admin-note svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-note b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sec-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.0125rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.sec-title.danger {
|
||||
color: var(--red);
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1.875rem;
|
||||
}
|
||||
.fblock {
|
||||
padding: 1.25rem 1.375rem;
|
||||
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.3125rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: 5.25rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.8125rem;
|
||||
background: #f5f6f8;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-2);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.readonly .owner-avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #0ea5a3;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.readonly .slash {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.readonly .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.readonly .lock {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.readonly .lock svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* 공개 범위 라디오 카드 */
|
||||
.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-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f1f2f4;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rc-ico svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
}
|
||||
.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.4375rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.875rem 1.375rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
|
||||
/* 위험 구역 */
|
||||
.danger-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.danger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid #f6dde2;
|
||||
}
|
||||
.danger-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.dr-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dr-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.dr-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.1875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.btn-danger {
|
||||
height: 2.125rem;
|
||||
padding: 0 0.875rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--red-border);
|
||||
background: #fff;
|
||||
color: var(--red);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--red-weak);
|
||||
}
|
||||
.btn-danger.solid {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger.solid:hover {
|
||||
background: #c23a51;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,860 @@
|
||||
<script setup lang="ts">
|
||||
// 회원가입 — 가입 폼 (1단계: 이메일+JWT. 이메일 인증은 8단계 예정 → 가입 즉시 자동 로그인)
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 폼 상태
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const showPassword = ref(false)
|
||||
const showPasswordConfirm = ref(false)
|
||||
const agreeTerms = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨, 8단계에서 활성화)
|
||||
const step = ref<'form' | 'verify'>('form')
|
||||
const sentEmail = computed(() => email.value.trim() || 'name@acme.co')
|
||||
|
||||
// 가입 처리 — 검증 후 API 호출, 성공 시 자동 로그인되어 저장소 목록으로 이동
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
|
||||
// 클라이언트 1차 검증 (서버 DTO 가 최종 검증)
|
||||
if (!name.value.trim() || !email.value.trim() || !password.value) {
|
||||
window.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
window.alert('비밀번호는 최소 8자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
window.alert('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
if (!agreeTerms.value) {
|
||||
window.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await authStore.signup({
|
||||
name: name.value.trim(),
|
||||
email: email.value.trim(),
|
||||
password: password.value,
|
||||
})
|
||||
await router.push('/repos')
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
function backToForm() {
|
||||
step.value = 'form'
|
||||
}
|
||||
|
||||
const points = [
|
||||
'이메일로 1분이면 가입 완료',
|
||||
'이메일 인증으로 안전한 가입',
|
||||
'가입 후 바로 업무 흐름에 참여',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<!-- 브랜드 패널 -->
|
||||
<aside class="brand-panel">
|
||||
<div class="blob b1" />
|
||||
<div class="blob b2" />
|
||||
<div class="ring" />
|
||||
<div class="bp-logo">
|
||||
<span class="logo">R</span> Relay
|
||||
</div>
|
||||
<div class="bp-body">
|
||||
<div class="bp-head">
|
||||
팀에 합류하고<br>업무 지시를 시작하세요
|
||||
</div>
|
||||
<div class="bp-sub">
|
||||
계정을 만들면 저장소에 초대되어, 받은 업무를 확인하고 직접 업무를 지시할 수 있습니다.
|
||||
</div>
|
||||
<div class="bp-points">
|
||||
<div
|
||||
v-for="point in points"
|
||||
:key="point"
|
||||
class="bp-point"
|
||||
>
|
||||
<span class="ic">
|
||||
<svg
|
||||
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>
|
||||
</span>
|
||||
{{ point }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-foot">
|
||||
© 2026 Relay · 사내 업무 지시 플랫폼
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 폼 패널 -->
|
||||
<main class="form-panel">
|
||||
<div class="auth-card">
|
||||
<div class="ac-logo">
|
||||
<span class="logo">R</span> Relay
|
||||
</div>
|
||||
|
||||
<!-- 상태 1: 가입 폼 -->
|
||||
<form
|
||||
v-if="step === 'form'"
|
||||
class="signup-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h1 class="ac-title">
|
||||
이메일로 회원가입
|
||||
</h1>
|
||||
<p class="ac-sub">
|
||||
구글·카카오 계정은 <RouterLink
|
||||
class="inline-link"
|
||||
to="/login"
|
||||
>
|
||||
로그인 화면
|
||||
</RouterLink>에서 바로 시작할 수 있어요.
|
||||
</p>
|
||||
|
||||
<div class="field first">
|
||||
<label
|
||||
class="flabel"
|
||||
for="su-name"
|
||||
>이름</label>
|
||||
<input
|
||||
id="su-name"
|
||||
v-model="name"
|
||||
class="tinput"
|
||||
type="text"
|
||||
placeholder="홍길동"
|
||||
autocomplete="name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label
|
||||
class="flabel"
|
||||
for="su-email"
|
||||
>이메일</label>
|
||||
<input
|
||||
id="su-email"
|
||||
v-model="email"
|
||||
class="tinput"
|
||||
type="email"
|
||||
placeholder="name@acme.co"
|
||||
autocomplete="email"
|
||||
>
|
||||
<div class="field-hint">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" />
|
||||
</svg>
|
||||
가입 후 이 주소로 인증 메일이 발송됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label
|
||||
class="flabel"
|
||||
for="su-pw"
|
||||
>비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
<input
|
||||
id="su-pw"
|
||||
v-model="password"
|
||||
class="tinput"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="비밀번호 입력"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<button
|
||||
class="pw-toggle"
|
||||
type="button"
|
||||
:aria-label="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-hint">
|
||||
<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>
|
||||
8자 이상, 영문과 숫자를 포함해주세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label
|
||||
class="flabel"
|
||||
for="su-pw2"
|
||||
>비밀번호 확인</label>
|
||||
<div class="pw-wrap">
|
||||
<input
|
||||
id="su-pw2"
|
||||
v-model="passwordConfirm"
|
||||
class="tinput"
|
||||
:type="showPasswordConfirm ? 'text' : 'password'"
|
||||
placeholder="비밀번호 다시 입력"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<button
|
||||
class="pw-toggle"
|
||||
type="button"
|
||||
:aria-label="showPasswordConfirm ? '비밀번호 숨기기' : '비밀번호 표시'"
|
||||
@click="showPasswordConfirm = !showPasswordConfirm"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="terms"
|
||||
type="button"
|
||||
@click="agreeTerms = !agreeTerms"
|
||||
>
|
||||
<span
|
||||
class="box"
|
||||
:class="{ on: agreeTerms }"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span><a href="#">이용약관</a> 및 <a href="#">개인정보 처리방침</a>에 동의합니다. (필수)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="submit"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" />
|
||||
</svg>
|
||||
{{ submitting ? '가입 중…' : '가입하고 시작하기' }}
|
||||
</button>
|
||||
|
||||
<div class="alt">
|
||||
이미 계정이 있으신가요? <RouterLink to="/login">
|
||||
로그인
|
||||
</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 상태 2: 인증 메일 전송됨 -->
|
||||
<div
|
||||
v-else
|
||||
class="verify"
|
||||
>
|
||||
<div class="vicon">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>이메일을 확인해주세요</h1>
|
||||
<div class="vtext">
|
||||
아래 주소로 인증 메일을 보냈습니다.<br>메일 속 링크를 클릭하면 가입이 완료됩니다.
|
||||
</div>
|
||||
<div class="vmail">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="20"
|
||||
height="16"
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" />
|
||||
</svg>
|
||||
<span>{{ sentEmail }}</span>
|
||||
</div>
|
||||
<div class="vspam">
|
||||
메일이 보이지 않나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.
|
||||
</div>
|
||||
<div class="vactions">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
인증 메일 다시 보내기
|
||||
</button>
|
||||
<button
|
||||
class="vchange"
|
||||
type="button"
|
||||
@click="backToForm"
|
||||
>
|
||||
다른 이메일로 가입하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 회원가입도 로그인과 동일한 auth 레이아웃(큰 모서리 반경) */
|
||||
.auth {
|
||||
--radius: 8px;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- 브랜드 패널 ---- */
|
||||
.brand-panel {
|
||||
flex: 0 0 46%;
|
||||
max-width: 38.75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
|
||||
color: #fff;
|
||||
padding: 2.75rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-panel .blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-panel .b1 {
|
||||
width: 22.5rem;
|
||||
height: 22.5rem;
|
||||
right: -7.5rem;
|
||||
top: -5.625rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.brand-panel .b2 {
|
||||
width: 16.25rem;
|
||||
height: 16.25rem;
|
||||
left: -5.625rem;
|
||||
bottom: -4.375rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.brand-panel .ring {
|
||||
position: absolute;
|
||||
right: 3.75rem;
|
||||
bottom: 5rem;
|
||||
width: 11.25rem;
|
||||
height: 11.25rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand-panel .ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2.125rem;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: -0.0125rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-logo .logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.bp-body {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.bp-head {
|
||||
font-size: 2.125rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.05rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.bp-sub {
|
||||
margin-top: 1.125rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
max-width: 25rem;
|
||||
}
|
||||
.bp-points {
|
||||
margin-top: 1.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.bp-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.bp-point .ic {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bp-point .ic svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.bp-foot {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- 폼 패널 ---- */
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.ac-logo {
|
||||
display: none;
|
||||
}
|
||||
.ac-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03125rem;
|
||||
}
|
||||
.ac-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.4375rem;
|
||||
}
|
||||
.inline-link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.8125rem;
|
||||
}
|
||||
.field.first {
|
||||
margin-top: 1.625rem;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.4375rem;
|
||||
display: block;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8125rem;
|
||||
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);
|
||||
}
|
||||
.pw-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.pw-wrap .tinput {
|
||||
padding-right: 2.75rem;
|
||||
}
|
||||
.pw-toggle {
|
||||
position: absolute;
|
||||
right: 0.375rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.pw-toggle:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pw-toggle svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.4375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
.field-hint svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5625rem;
|
||||
margin: 0.375rem 0 1.125rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.terms .box {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.terms .box.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.terms .box svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.terms .box.on svg {
|
||||
opacity: 1;
|
||||
}
|
||||
.terms a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.terms a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 2.875rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.submit svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
}
|
||||
|
||||
.alt {
|
||||
margin-top: 1.375rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.alt a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.alt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 인증 메일 전송 상태 */
|
||||
.verify {
|
||||
text-align: center;
|
||||
}
|
||||
.verify .vicon {
|
||||
width: 4.125rem;
|
||||
height: 4.125rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto 1.375rem;
|
||||
}
|
||||
.verify .vicon svg {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
}
|
||||
.verify h1 {
|
||||
font-size: 1.4375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
.verify .vtext {
|
||||
color: var(--text-2);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.verify .vmail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5625rem 0.875rem;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.verify .vmail svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.verify .vspam {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 1.125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.verify .vactions {
|
||||
margin-top: 1.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
height: 2.75rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.btn-ghost svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.vchange {
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.vchange:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 좁은 화면 — 브랜드 패널 숨기고 카드 로고 노출 */
|
||||
@media (max-width: 55rem) {
|
||||
.brand-panel {
|
||||
display: none;
|
||||
}
|
||||
.ac-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.0625rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.ac-logo .logo {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,987 @@
|
||||
<script setup lang="ts">
|
||||
// 새 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
|
||||
// 제목
|
||||
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
|
||||
|
||||
// 담당자 (칩)
|
||||
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
|
||||
function removeAssignee(id: string) {
|
||||
assignees.value = assignees.value.filter((a) => a.id !== id)
|
||||
}
|
||||
|
||||
// 체크리스트
|
||||
const checklist = ref<ChecklistItem[]>([
|
||||
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
|
||||
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
|
||||
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
|
||||
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
|
||||
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
|
||||
])
|
||||
const newItem = ref('')
|
||||
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
|
||||
const progressPct = computed(() =>
|
||||
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
|
||||
)
|
||||
function toggleItem(item: ChecklistItem) {
|
||||
item.done = !item.done
|
||||
}
|
||||
function removeItem(index: number) {
|
||||
checklist.value.splice(index, 1)
|
||||
}
|
||||
function addItem() {
|
||||
const text = newItem.value.trim()
|
||||
if (!text) return
|
||||
checklist.value.push({ text, done: false })
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
// 첨부파일
|
||||
const files = ref<Attachment[]>([
|
||||
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
|
||||
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
|
||||
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
|
||||
])
|
||||
function removeFile(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 파일 종류 → 아이콘 클래스/라벨
|
||||
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
pdf: { cls: 'fi-pdf', label: 'PDF' },
|
||||
zip: { cls: 'fi-zip', label: 'ZIP' },
|
||||
doc: { cls: 'fi-doc', label: 'DOC' },
|
||||
img: { cls: 'fi-doc', label: 'IMG' },
|
||||
}
|
||||
|
||||
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
|
||||
function goBack() {
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo?.name ?? '저장소' }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 업무
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>새 업무 작성</h1>
|
||||
<span class="draft-tag">임시저장됨 · 방금</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="goBack"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
>
|
||||
임시저장
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
@click="goBack"
|
||||
>
|
||||
<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="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
지시 보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- 메인 -->
|
||||
<section class="card main-card">
|
||||
<input
|
||||
v-model="title"
|
||||
class="title-input"
|
||||
placeholder="업무 제목을 입력하세요"
|
||||
>
|
||||
<div class="divider" />
|
||||
|
||||
<!-- 업무 내용 -->
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="req">*</span> 업무 내용
|
||||
</div>
|
||||
<div class="editor">
|
||||
<div class="editor-toolbar">
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
style="font-weight: 800"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
style="font-style: italic; font-family: Georgia, serif"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
style="text-decoration: underline"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
<span class="tb-sep" />
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
title="목록"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
title="번호 목록"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4l2-3H4" /></svg>
|
||||
</button>
|
||||
<span class="tb-sep" />
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
title="링크"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
title="멘션"
|
||||
>
|
||||
@
|
||||
</button>
|
||||
<button
|
||||
class="tb"
|
||||
type="button"
|
||||
title="코드"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<p>9월 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1차 시안 확정 후 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
|
||||
<p>각 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 -->
|
||||
<div class="field">
|
||||
<div class="check-head">
|
||||
<div
|
||||
class="field-label"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
해야 할 일
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar">
|
||||
<i :style="{ width: progressPct + '%' }" />
|
||||
</div>
|
||||
<span class="progress-txt">{{ doneCount }} / {{ checklist.length }} 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div
|
||||
v-for="(item, index) in checklist"
|
||||
:key="index"
|
||||
class="check-item"
|
||||
:class="{ done: item.done }"
|
||||
>
|
||||
<span
|
||||
class="cbox"
|
||||
:class="{ done: item.done }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="txt">{{ item.text }}</span>
|
||||
<span
|
||||
class="row-del"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<span class="plus">+</span>
|
||||
<input
|
||||
v-model="newItem"
|
||||
class="add-input"
|
||||
placeholder="항목 추가"
|
||||
@keydown.enter="addItem"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 사이드바 -->
|
||||
<aside class="side">
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}명</span>
|
||||
</div>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<span
|
||||
v-for="a in assignees"
|
||||
:key="a.id"
|
||||
class="chip"
|
||||
>
|
||||
<span
|
||||
class="avatar"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
{{ a.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeAssignee(a.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
<input
|
||||
class="combo-input"
|
||||
placeholder="이름 검색…"
|
||||
>
|
||||
<span class="combo-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>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
<span class="req">*</span> 마감기한
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
||||
</span>
|
||||
2026년 6월 30일 (화) 18:00
|
||||
</div>
|
||||
<div class="date-meta">
|
||||
<span class="dot" /> 마감까지 15일 남음
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 <span class="muted">· {{ files.length }}개</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
class="file-ico"
|
||||
:class="FILE_META[file.kind].cls"
|
||||
>{{ FILE_META[file.kind].label }}</span>
|
||||
<span class="file-info">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-size">{{ file.size }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="x"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropzone">
|
||||
파일을 끌어다 놓거나 <b>찾아보기</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field bare">
|
||||
<div class="side-label">
|
||||
지시자
|
||||
</div>
|
||||
<div class="issuer">
|
||||
<span class="avatar av-f">정</span>
|
||||
<span>
|
||||
<div class="nm">정태경</div>
|
||||
<div class="rl">마케팅 그룹 리드</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 77.5rem;
|
||||
}
|
||||
.crumb {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0 1.125rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.3125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.draft-tag {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
border: 1px solid var(--amber-border);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagehead .actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pagehead .btn {
|
||||
height: 2.125rem;
|
||||
}
|
||||
|
||||
/* 레이아웃 */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 21.75rem;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.main-card {
|
||||
padding: 0.5rem 1.625rem 1.625rem;
|
||||
}
|
||||
|
||||
/* 제목 입력 */
|
||||
.title-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03125rem;
|
||||
color: var(--text);
|
||||
padding: 1.25rem 0 0.875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: #c4c8cf;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0 -1.625rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding-top: 1.375rem;
|
||||
}
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.5625rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.field-label .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 에디터 */
|
||||
.editor {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.tb {
|
||||
height: 1.75rem;
|
||||
min-width: 1.75rem;
|
||||
padding: 0 0.4375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tb:hover {
|
||||
background: #eceef1;
|
||||
color: var(--text);
|
||||
}
|
||||
.tb svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.tb-sep {
|
||||
width: 1px;
|
||||
height: 1.125rem;
|
||||
background: var(--border-strong);
|
||||
margin: 0 0.3125rem;
|
||||
}
|
||||
.editor-body {
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
min-height: 9.375rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.editor-body p {
|
||||
margin-bottom: 0.6875rem;
|
||||
}
|
||||
.editor-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mention {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
background: var(--accent-weak);
|
||||
padding: 0 0.1875rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 체크리스트 */
|
||||
.check-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
.progress-wrap {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 5.75rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: #eceef1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--green);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.progress-txt {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
padding: 0.5625rem 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.check-item:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.cbox {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cbox.done {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.cbox svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.cbox.done svg {
|
||||
opacity: 1;
|
||||
}
|
||||
.check-item .txt {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
.check-item.done .txt {
|
||||
color: var(--text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.check-item .row-del {
|
||||
opacity: 0;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.check-item:hover .row-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.check-item .row-del:hover {
|
||||
background: #eceef1;
|
||||
color: var(--red);
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
padding: 0.625rem 0 0.25rem;
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
.add-row .plus {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.add-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.side-card {
|
||||
padding: 1rem 1.125rem 1.125rem;
|
||||
}
|
||||
.side-field + .side-field {
|
||||
margin-top: 1.125rem;
|
||||
padding-top: 1.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.side-field.bare {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.side-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.5625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-label .req {
|
||||
color: var(--accent);
|
||||
}
|
||||
.side-label .muted {
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 담당자 콤보 */
|
||||
.combo {
|
||||
position: relative;
|
||||
}
|
||||
.combo-field {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4375rem 0.5rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.combo-field:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.combo-caret {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.combo-caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .avatar {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
font-size: 0.656rem;
|
||||
}
|
||||
.chip .x {
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
margin-left: 0.0625rem;
|
||||
}
|
||||
.chip .x:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.combo-input {
|
||||
flex: 1;
|
||||
min-width: 4.375rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
background: transparent;
|
||||
}
|
||||
.combo-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 마감기한 입력 */
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 2.375rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.6875rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.input .ico {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.input .ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.date-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
margin-top: 0.4375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
.date-meta .dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
/* 첨부파일 */
|
||||
.dropzone {
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8125rem;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.781rem;
|
||||
cursor: pointer;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dropzone:hover {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.dropzone b {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4375rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.5625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.file-ico {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 0.375rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: -0.01875rem;
|
||||
}
|
||||
.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.file-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.file-name {
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.file-size {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
display: block;
|
||||
}
|
||||
.file .x {
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file .x:hover {
|
||||
background: #eceef1;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* 지시자 */
|
||||
.issuer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.issuer .avatar {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.issuer .nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.issuer .rl {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-foot {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user