first commit
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"oxc.oxc-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS build-stage
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
RUN npm install -g npm@11
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_MODE=production
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
|
||||
# 보안 헤더 포함된 nginx 설정 적용
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,48 @@
|
||||
# frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||
|
||||
// Flat config — Vue 3 + TypeScript 표준 권장 룰
|
||||
export default [
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
...vueTsEslintConfig(),
|
||||
{
|
||||
rules: {
|
||||
// 컴포넌트 단어 수 강제 해제 (HomePage 등 단일 컴포넌트도 허용)
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Relay — 사내 업무 지시 플랫폼" />
|
||||
<!-- Pretendard 웹폰트 (디자인 시안 기준 서체) -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css"
|
||||
/>
|
||||
<title>Relay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# ----------------------------------------
|
||||
# 보안 헤더
|
||||
# ----------------------------------------
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip)
|
||||
# ----------------------------------------
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
|
||||
|
||||
# ----------------------------------------
|
||||
# SPA 라우팅
|
||||
# ----------------------------------------
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
|
||||
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
# 숨김 파일 접근 차단
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
Generated
+7053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "oxfmt src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.2.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxfmt": "^0.42.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.2",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Relay 디자인 시스템 — 전역 스타일
|
||||
* - 디자인 토큰(CSS 변수)과 화면 전반에서 공유하는 공통 컴포넌트 클래스를 정의한다.
|
||||
* - 시안(px) → rem 변환(기준 16px = 1rem). 단, 1px 경계선/그림자는 px 유지(헤어라인 관례).
|
||||
* - 화면 고유 스타일은 각 SFC 의 <style scoped> 에 둔다.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
* 1. 디자인 토큰
|
||||
* ============================================================ */
|
||||
:root {
|
||||
/* 표면 / 경계 */
|
||||
--bg: #f4f5f7;
|
||||
--panel: #ffffff;
|
||||
--border: #e6e8ec;
|
||||
--border-strong: #d7dae0;
|
||||
|
||||
/* 텍스트 */
|
||||
--text: #16181d;
|
||||
--text-2: #5b626e;
|
||||
--text-3: #9298a3;
|
||||
|
||||
/* 강조(보라) */
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--accent-weak: #eef0fe;
|
||||
--accent-border: #c7ccfb;
|
||||
|
||||
/* 상태 색상 */
|
||||
--green: #1f9d57;
|
||||
--green-weak: #e8f6ee;
|
||||
--green-border: #cde8d8;
|
||||
--blue: #1d4ed8;
|
||||
--blue-weak: #e8efff;
|
||||
--blue-border: #c9dbff;
|
||||
--red: #d6455d;
|
||||
--red-weak: #fdeef0;
|
||||
--red-border: #f1c0c8;
|
||||
--amber: #b7791f;
|
||||
--amber-weak: #fdf4e3;
|
||||
--amber-border: #f3e2bd;
|
||||
|
||||
/* 소셜 */
|
||||
--kakao: #fee500;
|
||||
--kakao-text: #191600;
|
||||
|
||||
/* 형태 */
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
|
||||
/* 그림자 */
|
||||
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
|
||||
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
|
||||
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 2. 리셋 / 베이스
|
||||
* ============================================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
|
||||
font-size: 14px; /* 기준 본문 크기(시안 기준) */
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 3. 페이지 컨테이너 / 브레드크럼
|
||||
* ============================================================ */
|
||||
.page {
|
||||
max-width: 67.5rem; /* 1080px */
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 4.375rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
color: var(--text-3);
|
||||
font-size: 0.781rem;
|
||||
padding: 1.125rem 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb .lnk {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumb .lnk:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.crumb .sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.crumb b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 4. 버튼
|
||||
* ============================================================ */
|
||||
.btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.9375rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: #eceef1;
|
||||
}
|
||||
.btn.sm {
|
||||
height: 1.875rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.781rem;
|
||||
}
|
||||
.btn.icon {
|
||||
width: 2.125rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 5. 아바타
|
||||
* ============================================================ */
|
||||
.avatar {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.av-a {
|
||||
background: #e0518d;
|
||||
}
|
||||
.av-b {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.av-c {
|
||||
background: #16a37b;
|
||||
}
|
||||
.av-d {
|
||||
background: #b3631f;
|
||||
}
|
||||
.av-e {
|
||||
background: #7c5cf0;
|
||||
}
|
||||
.av-f {
|
||||
background: #d0982a;
|
||||
}
|
||||
.av-more {
|
||||
background: #eceef1;
|
||||
color: var(--text-2);
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
/* 겹쳐 쌓는 아바타 스택 */
|
||||
.avstack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.avstack .avatar {
|
||||
margin-left: -0.4375rem;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
.avstack .avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 6. 상태 배지 / 상태 점 / 공개여부 배지 / 카운트 필
|
||||
* ============================================================ */
|
||||
.st-badge {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1875rem 0.625rem;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
.st-badge.prog {
|
||||
color: var(--blue);
|
||||
background: var(--blue-weak);
|
||||
}
|
||||
.st-badge.review {
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
}
|
||||
.st-badge.changes {
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
}
|
||||
.st-badge.done {
|
||||
color: var(--green);
|
||||
background: var(--green-weak);
|
||||
}
|
||||
.st-badge.todo {
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
}
|
||||
|
||||
/* 상태 점(목록/그룹 헤더 공용) */
|
||||
.st-dot {
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.st-dot.done {
|
||||
background: var(--green);
|
||||
}
|
||||
.st-dot.prog {
|
||||
background: var(--blue);
|
||||
}
|
||||
.st-dot.review {
|
||||
background: var(--amber);
|
||||
}
|
||||
.st-dot.todo {
|
||||
background: #c4c8cf;
|
||||
}
|
||||
.st-dot.changes {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
/* 공개여부 배지 */
|
||||
.vis {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vis svg {
|
||||
width: 0.6875rem;
|
||||
height: 0.6875rem;
|
||||
}
|
||||
.vis.private {
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.vis.public {
|
||||
color: #1b7a3d;
|
||||
background: var(--green-weak);
|
||||
border: 1px solid var(--green-border);
|
||||
}
|
||||
|
||||
/* 카운트 필 */
|
||||
.count-pill {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 0.125rem 0.6875rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 7. 진행률 바
|
||||
* ============================================================ */
|
||||
.pbar {
|
||||
height: 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: #eceef1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pbar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--green);
|
||||
}
|
||||
.pbar > i.mid {
|
||||
background: var(--accent);
|
||||
}
|
||||
.pbar > i.low {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 8. 툴바 구성요소 (검색 / 세그먼트 / 정렬)
|
||||
* ============================================================ */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.6875rem;
|
||||
background: #fff;
|
||||
width: 17.5rem;
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.search svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
background: transparent;
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
background: #eceef1;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.1875rem;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.segmented button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
padding: 0.3125rem 0.8125rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.segmented button.active {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.segmented button .n {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.segmented button.active .n {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sort {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
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;
|
||||
}
|
||||
.sort svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 9. 카드 / 목록 컨테이너
|
||||
* ============================================================ */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 10. 모달 공통 백드롭 (세부 모달은 각 화면에서 정의)
|
||||
* ============================================================ */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 24, 33, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-backdrop.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 27.5rem;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 1.625rem 1.5rem 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-body.left {
|
||||
text-align: left;
|
||||
padding: 1.375rem 1.5rem 0.5rem;
|
||||
}
|
||||
.modal-ico {
|
||||
width: 2.875rem;
|
||||
height: 2.875rem;
|
||||
border-radius: 50%;
|
||||
background: var(--red-weak);
|
||||
color: var(--red);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto 0.875rem;
|
||||
}
|
||||
.modal-ico.approve {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.modal-ico svg {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
}
|
||||
.modal-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.modal-desc.spaced {
|
||||
margin: 0.5rem 0 0.875rem;
|
||||
}
|
||||
.modal-desc b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal-foot {
|
||||
display: flex;
|
||||
gap: 0.5625rem;
|
||||
padding: 1.125rem 1.5rem 1.25rem;
|
||||
}
|
||||
.mbtn {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
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;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mbtn:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.mbtn.danger {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(214, 69, 93, 0.4);
|
||||
}
|
||||
.mbtn.danger:hover {
|
||||
background: #c23a51;
|
||||
}
|
||||
.mbtn.approve {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(31, 157, 87, 0.4);
|
||||
}
|
||||
.mbtn.approve:hover {
|
||||
background: #188a4b;
|
||||
}
|
||||
.mbtn.request-send {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.mbtn.request-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.modal-textarea {
|
||||
width: 100%;
|
||||
min-height: 5.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.modal-textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Repo, User } from '@/mock/relay.mock'
|
||||
|
||||
interface Props {
|
||||
repo: Repo
|
||||
/** 메타 우측 텍스트(예: "2시간 전 업데이트", "멤버 5명") */
|
||||
metaText: string
|
||||
/** 아바타 스택 override (기본: repo.members) */
|
||||
members?: User[]
|
||||
/** +N 표기 (기본: repo.moreCount) */
|
||||
moreCount?: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const avatars = computed(() => props.members ?? props.repo.members)
|
||||
const extra = computed(() => props.moreCount ?? props.repo.moreCount)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="repo-head">
|
||||
<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="rh-main">
|
||||
<div class="rh-title">
|
||||
<span class="rh-name">{{ repo.name }}</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="rh-slug">
|
||||
{{ repo.owner }} / {{ repo.id }}
|
||||
</div>
|
||||
<div class="rh-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="rh-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 avatars"
|
||||
:key="m.id"
|
||||
class="avatar"
|
||||
:class="m.color"
|
||||
>{{ m.initial }}</span>
|
||||
<span
|
||||
v-if="extra > 0"
|
||||
class="avatar av-more"
|
||||
>+{{ extra }}</span>
|
||||
</span>
|
||||
<span class="mi">{{ metaText }}</span>
|
||||
</div>
|
||||
<!-- 진행률 스트립 등 추가 영역 -->
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<slot name="actions">
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
:to="`/repos/${repo.id}/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>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.repo-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
padding: 1.25rem 1.375rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.repo-ico {
|
||||
width: 2.875rem;
|
||||
height: 2.875rem;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.repo-ico svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.rh-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rh-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.rh-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rh-slug {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.3125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.rh-desc {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.rh-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9375rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rh-meta .mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rh-meta .mi svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.rh-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 서브탭 — 업무/멤버/활동/설정
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
repoId: string
|
||||
active: 'tasks' | 'members' | 'activity' | 'settings'
|
||||
taskCount: number
|
||||
memberCount: number
|
||||
}
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="tabs">
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:class="{ active: active === 'tasks' }"
|
||||
>
|
||||
<span class="tb-label">업무</span> <span class="tb-count">{{ taskCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}/members`"
|
||||
:class="{ active: active === 'members' }"
|
||||
>
|
||||
<span class="tb-label">멤버</span> <span class="tb-count">{{ memberCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}/activity`"
|
||||
:class="{ active: active === 'activity' }"
|
||||
>
|
||||
<span class="tb-label">활동</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}/settings`"
|
||||
:class="{ active: active === 'settings' }"
|
||||
>
|
||||
<span class="tb-label">설정</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.125rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 1.25rem 0 1.125rem;
|
||||
}
|
||||
.tabs a {
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.tabs a.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.tabs a .tb-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border-radius: 20px;
|
||||
padding: 0 0.4375rem;
|
||||
height: 1.0625rem;
|
||||
min-width: 1.0625rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.tabs a.active .tb-count {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// axios 요청 설정 확장 — 호출 단위 옵션
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
/** true 면 에러 발생 시 전역 알림(UI) 을 띄우지 않는다 (예: 부트스트랩용 /auth/me) */
|
||||
silent?: boolean
|
||||
/** 내부용 — 401 재시도(토큰 refresh) 1회 수행 여부 플래그 */
|
||||
_retry?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
const ErrorCodeLexicon: Record<string, string> = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
// 에러 코드 → 메시지 변환 (미정의 코드는 SYS_001 로 폴백, 항상 문자열 보장)
|
||||
const SYSTEM_ERROR_MESSAGE =
|
||||
ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
const resolveErrorMessage = (code: string): string => ErrorCodeLexicon[code] ?? SYSTEM_ERROR_MESSAGE
|
||||
|
||||
// 사용자 노출 메시지 결정 — 비즈니스 예외(BIZ_001)는 서버가 내려준 구체 문구를 우선 사용
|
||||
// (CLAUDE.md: "비즈니스 예외는 message 로 사용자 노출 문구를 재정의할 수 있다")
|
||||
const pickMessage = (code: string, serverMessage?: string): string =>
|
||||
code === 'BIZ_001' && serverMessage ? serverMessage : resolveErrorMessage(code)
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => config,
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외
|
||||
const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/')
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 401 자동 refresh + 에러코드 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
const payload = response.data
|
||||
|
||||
if (payload && typeof payload.success === 'boolean') {
|
||||
if (payload.success) {
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
if (!response.config.silent) showErrorUI(pickMessage(errCode, payload.error?.message))
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined
|
||||
const status: number | undefined = error?.response?.status
|
||||
|
||||
// access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도
|
||||
if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) {
|
||||
config._retry = true
|
||||
try {
|
||||
await api.post('/auth/refresh', null, { silent: true })
|
||||
return api(config)
|
||||
} catch {
|
||||
// refresh 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행
|
||||
}
|
||||
}
|
||||
|
||||
let errCode = 'SYS_001'
|
||||
let serverMessage: string | undefined
|
||||
if (error?.response) {
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
serverMessage = payload?.error?.message
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
if (!config?.silent) showErrorUI(pickMessage(errCode, serverMessage))
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
|
||||
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
|
||||
*/
|
||||
export function useApi() {
|
||||
return api
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
|
||||
|
||||
// 인증 도메인 API Composable — 인터셉터가 success/data 를 언래핑하므로 data 타입으로 캐스팅
|
||||
export function useAuth() {
|
||||
const api = useApi()
|
||||
|
||||
// 회원가입 (성공 시 백엔드가 인증 쿠키를 설정해 자동 로그인됨)
|
||||
async function signup(payload: SignupPayload): Promise<AuthUser> {
|
||||
return (await api.post('/auth/signup', payload)) as unknown as AuthUser
|
||||
}
|
||||
|
||||
// 로그인
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
return (await api.post('/auth/login', payload)) as unknown as AuthUser
|
||||
}
|
||||
|
||||
// 로그아웃 (쿠키 제거)
|
||||
async function logout(): Promise<void> {
|
||||
await api.post('/auth/logout')
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 조회 — 부트스트랩/가드용. 미인증이면 throw 되므로 호출측에서 처리
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
// silent: 미인증(401) 시 전역 알림을 띄우지 않는다
|
||||
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
|
||||
}
|
||||
|
||||
return { signup, login, logout, fetchMe }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
|
||||
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(): Promise<ApiRepo[]> {
|
||||
return (await api.get('/repos')) as unknown as ApiRepo[]
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiRepo> {
|
||||
return (await api.get(`/repos/${id}`)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function create(payload: CreateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.post('/repos', payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function update(id: string, payload: UpdateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.patch(`/repos/${id}`, payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await api.delete(`/repos/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
// 앱 공통 셸 — 상단 탑바(브랜드/네비/우측 액션) + 본문 슬롯
|
||||
// 모든 인증 이후 화면이 이 레이아웃을 공유한다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 현재 경로 기준으로 상단 네비 활성 항목을 판별
|
||||
const activeNav = computed<'tasks' | 'repos'>(() =>
|
||||
route.path.startsWith('/repos') ? 'repos' : 'tasks',
|
||||
)
|
||||
|
||||
// 현재 로그인 사용자 표시(아바타 이니셜 = 이름 첫 글자)
|
||||
const me = computed(() => authStore.user)
|
||||
const meInitial = computed(() => me.value?.name?.charAt(0) ?? '?')
|
||||
|
||||
// 사용자 메뉴 토글
|
||||
const menuOpen = ref(false)
|
||||
|
||||
// 로그아웃 → 로그인 화면으로
|
||||
async function onLogout() {
|
||||
menuOpen.value = false
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="brand"
|
||||
>
|
||||
<div class="logo">
|
||||
R
|
||||
</div>
|
||||
Relay
|
||||
</RouterLink>
|
||||
|
||||
<nav class="topnav">
|
||||
<RouterLink
|
||||
to="/tasks"
|
||||
:class="{ active: activeNav === 'tasks' }"
|
||||
>
|
||||
내 업무
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
:class="{ active: activeNav === 'repos' }"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="topbar-right">
|
||||
<!-- 화면별 추가 액션(예: AI 에이전트 버튼)을 끼워 넣는 슬롯 -->
|
||||
<slot name="actions" />
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="검색"
|
||||
aria-label="검색"
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="알림"
|
||||
aria-label="알림"
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="me-wrap">
|
||||
<button
|
||||
class="me"
|
||||
type="button"
|
||||
:title="me?.name ?? '사용자'"
|
||||
:aria-label="`${me?.name ?? '사용자'} 메뉴`"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="menuOpen"
|
||||
@click="menuOpen = !menuOpen"
|
||||
>
|
||||
{{ meInitial }}
|
||||
</button>
|
||||
|
||||
<!-- 사용자 드롭다운 -->
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="me-menu"
|
||||
role="menu"
|
||||
>
|
||||
<div class="me-info">
|
||||
<div class="me-name">
|
||||
{{ me?.name }}
|
||||
</div>
|
||||
<div class="me-email">
|
||||
{{ me?.email }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="me-action"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="onLogout"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
<!-- 메뉴 바깥 클릭 시 닫기 -->
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="me-overlay"
|
||||
@click="menuOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
height: 3.25rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
padding: 0 1.25rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
letter-spacing: -0.0125rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand .logo {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 0.4375rem;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.35);
|
||||
}
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.topnav a {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 500;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topnav a:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.topnav a.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.me-wrap {
|
||||
position: relative;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.me {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #0ea5a3;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 */
|
||||
.me-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
min-width: 11rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
.me-info {
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.me-name {
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.me-email {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.me-action {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.me-action:hover {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
/* 메뉴 바깥 클릭 감지용 투명 오버레이 */
|
||||
.me-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Relay 디자인 시스템 전역 스타일(토큰 + 공통 컴포넌트 클래스)
|
||||
import '@/assets/styles/relay.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* Relay 목업 데이터 — 프론트 UI 우선 구성용 정적 데이터
|
||||
* 추후 backend API 연동 시 composable/store 에서 이 데이터를 실제 응답으로 교체한다.
|
||||
*/
|
||||
|
||||
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
|
||||
export type AvatarColor = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
|
||||
|
||||
/** 업무 상태 */
|
||||
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
|
||||
|
||||
/** 저장소 공개 여부 */
|
||||
export type Visibility = 'private' | 'public'
|
||||
|
||||
/** 사용자(멤버) */
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
initial: string
|
||||
color: AvatarColor
|
||||
}
|
||||
|
||||
/** 첨부파일 */
|
||||
export interface Attachment {
|
||||
name: string
|
||||
size: string
|
||||
/** 파일 아이콘 종류 */
|
||||
kind: 'pdf' | 'zip' | 'doc' | 'img'
|
||||
}
|
||||
|
||||
/** 체크리스트 항목 */
|
||||
export interface ChecklistItem {
|
||||
text: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/** 저장소 */
|
||||
export interface Repo {
|
||||
/** 라우팅 식별자(slug 의 마지막 segment) */
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
slug: string
|
||||
desc: string
|
||||
visibility: Visibility
|
||||
branch: string
|
||||
members: User[]
|
||||
/** 멤버 스택에서 추가로 더 있는 인원 수(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
/** 진행률 바 색상 단계 */
|
||||
progressLevel: 'done' | 'mid' | 'low'
|
||||
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
|
||||
progressLabel: string
|
||||
}
|
||||
|
||||
/** 저장소 상세의 업무 목록 행 */
|
||||
export interface RepoTask {
|
||||
id: number
|
||||
title: string
|
||||
status: TaskStatus
|
||||
/** 마감 라벨(D-3 / 2일 지남 / 6월 9일 완료 등) */
|
||||
dueLabel?: string
|
||||
overdue?: boolean
|
||||
/** 체크리스트 [완료, 전체] */
|
||||
checklist?: [number, number]
|
||||
/** 코멘트 수 */
|
||||
comments?: number
|
||||
assignees: User[]
|
||||
}
|
||||
|
||||
/** 댓글의 답글 */
|
||||
export interface Reply {
|
||||
author: User
|
||||
time: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 댓글 */
|
||||
export interface Comment {
|
||||
author: User
|
||||
/** 작성자 역할 배지(예: 지시자) */
|
||||
roleBadge?: string
|
||||
time: string
|
||||
text: string
|
||||
file?: string
|
||||
replies: Reply[]
|
||||
}
|
||||
|
||||
/** 활동 타임라인 항목 */
|
||||
export interface Activity {
|
||||
/** 아이콘 색상 톤 */
|
||||
tone: '' | 'green' | 'amber' | 'blue' | 'accent'
|
||||
/** 아이콘 종류 */
|
||||
icon: 'check-circle' | 'check' | 'paperclip' | 'calendar' | 'users' | 'flag'
|
||||
/** 본문(굵게 처리는 컴포넌트에서 b 태그로) */
|
||||
html: string
|
||||
time: string
|
||||
file?: string
|
||||
statusFrom?: string
|
||||
statusTo?: string
|
||||
}
|
||||
|
||||
/** 업무 상세 */
|
||||
export interface TaskDetail {
|
||||
id: number
|
||||
repoId: string
|
||||
repoName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
statusLabel: string
|
||||
/** 본문 문단 (멘션은 @로 시작) */
|
||||
content: string[]
|
||||
checklist: ChecklistItem[]
|
||||
comments: Comment[]
|
||||
activities: Activity[]
|
||||
assignees: User[]
|
||||
issuer: User
|
||||
dueLabel: string
|
||||
dday: string
|
||||
files: Attachment[]
|
||||
/** 승인 요청 정보(승인 대기 상태일 때) */
|
||||
approval?: {
|
||||
requester: string
|
||||
requestedAgo: string
|
||||
note: string
|
||||
}
|
||||
issuedLabel: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 사용자
|
||||
* ============================================================ */
|
||||
// satisfies 로 선언해 각 키 접근이 `User | undefined` 로 넓어지지 않도록 한다
|
||||
// (tsconfig 의 noUncheckedIndexedAccess 대응)
|
||||
export const USERS = {
|
||||
kim: { id: 'kim', name: '김서연', role: '콘텐츠 기획', initial: '김', color: 'av-a' },
|
||||
park: { id: 'park', name: '박지훈', role: '퍼포먼스 마케팅', initial: '박', color: 'av-b' },
|
||||
lee: { id: 'lee', name: '이도윤', role: '카피라이팅', initial: '이', color: 'av-c' },
|
||||
choi: { id: 'choi', name: '최유진', role: 'UX 디자인', initial: '최', color: 'av-d' },
|
||||
han: { id: 'han', name: '한승우', role: '브랜드 전략', initial: '한', color: 'av-e' },
|
||||
jung: { id: 'jung', name: '정태경', role: '마케팅 그룹 리드', initial: '정', color: 'av-f' },
|
||||
} satisfies Record<string, User>
|
||||
|
||||
/* ============================================================
|
||||
* 저장소 목록
|
||||
* ============================================================ */
|
||||
export const REPOS: Repo[] = [
|
||||
{
|
||||
id: 'q3-launch-campaign',
|
||||
name: '3분기 신제품 런칭 캠페인',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/q3-launch-campaign',
|
||||
desc: '3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수',
|
||||
visibility: 'private',
|
||||
branch: 'main',
|
||||
members: [USERS.kim, USERS.park, USERS.lee],
|
||||
moreCount: 2,
|
||||
updatedAgo: '2시간 전 업데이트',
|
||||
doneCount: 8,
|
||||
totalCount: 12,
|
||||
progressPct: 67,
|
||||
progressLevel: 'mid',
|
||||
progressLabel: '67% 완료',
|
||||
},
|
||||
{
|
||||
id: 'brand-refresh-2026',
|
||||
name: '브랜드 리뉴얼 2026',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/brand-refresh-2026',
|
||||
desc: '브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트',
|
||||
visibility: 'private',
|
||||
branch: 'main',
|
||||
members: [USERS.choi, USERS.jung, USERS.han],
|
||||
moreCount: 1,
|
||||
updatedAgo: '어제 업데이트',
|
||||
doneCount: 5,
|
||||
totalCount: 9,
|
||||
progressPct: 56,
|
||||
progressLevel: 'mid',
|
||||
progressLabel: '56% 완료',
|
||||
},
|
||||
{
|
||||
id: 'monthly-newsletter',
|
||||
name: '월간 뉴스레터 운영',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/monthly-newsletter',
|
||||
desc: '월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영',
|
||||
visibility: 'public',
|
||||
branch: 'main',
|
||||
members: [USERS.lee, USERS.park, USERS.kim],
|
||||
moreCount: 0,
|
||||
updatedAgo: '3일 전 업데이트',
|
||||
doneCount: 14,
|
||||
totalCount: 14,
|
||||
progressPct: 100,
|
||||
progressLevel: 'done',
|
||||
progressLabel: '100% 완료',
|
||||
},
|
||||
{
|
||||
id: 'seo-content-hub',
|
||||
name: 'SEO 콘텐츠 허브',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/seo-content-hub',
|
||||
desc: '검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브',
|
||||
visibility: 'private',
|
||||
branch: 'main',
|
||||
members: [USERS.park, USERS.han, USERS.lee],
|
||||
moreCount: 3,
|
||||
updatedAgo: '5시간 전 업데이트',
|
||||
doneCount: 2,
|
||||
totalCount: 11,
|
||||
progressPct: 18,
|
||||
progressLevel: 'low',
|
||||
progressLabel: '18% 완료',
|
||||
},
|
||||
{
|
||||
id: 'event-handbook',
|
||||
name: '사내 행사 핸드북',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/event-handbook',
|
||||
desc: '사내 행사 운영 가이드 및 준비 체크리스트 모음',
|
||||
visibility: 'public',
|
||||
branch: 'main',
|
||||
members: [USERS.jung, USERS.kim],
|
||||
moreCount: 0,
|
||||
updatedAgo: '1주 전 업데이트',
|
||||
doneCount: 0,
|
||||
totalCount: 4,
|
||||
progressPct: 0,
|
||||
progressLevel: 'low',
|
||||
progressLabel: '시작 전',
|
||||
},
|
||||
{
|
||||
id: 'design-system-assets',
|
||||
name: '디자인 시스템 에셋',
|
||||
owner: 'marketing-team',
|
||||
slug: 'marketing-team/design-system-assets',
|
||||
desc: '공통 디자인 시스템 에셋 및 컴포넌트 관리',
|
||||
visibility: 'private',
|
||||
branch: 'develop',
|
||||
members: [USERS.kim, USERS.choi, USERS.lee],
|
||||
moreCount: 1,
|
||||
updatedAgo: '30분 전 업데이트',
|
||||
doneCount: 9,
|
||||
totalCount: 10,
|
||||
progressPct: 90,
|
||||
progressLevel: 'done',
|
||||
progressLabel: '90% 완료',
|
||||
},
|
||||
]
|
||||
|
||||
/* ============================================================
|
||||
* 저장소 상세 — 업무 목록 (q3-launch-campaign 기준)
|
||||
* ============================================================ */
|
||||
export const REPO_TASKS: Record<string, RepoTask[]> = {
|
||||
'q3-launch-campaign': [
|
||||
{
|
||||
id: 11,
|
||||
title: '법무 검토 요청 및 회신 반영',
|
||||
status: 'prog',
|
||||
dueLabel: '2일 지남',
|
||||
overdue: true,
|
||||
checklist: [1, 3],
|
||||
assignees: [USERS.lee],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
|
||||
status: 'review',
|
||||
dueLabel: 'D-3',
|
||||
checklist: [1, 3],
|
||||
comments: 2,
|
||||
assignees: [USERS.park, USERS.choi],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: '상세페이지 카피라이팅',
|
||||
status: 'todo',
|
||||
dueLabel: 'D-5',
|
||||
checklist: [0, 3],
|
||||
assignees: [USERS.lee],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'SNS 콘텐츠 1차 시안',
|
||||
status: 'todo',
|
||||
dueLabel: 'D-8',
|
||||
checklist: [0, 4],
|
||||
assignees: [USERS.han],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '캠페인 핵심 메시지 시안 작성',
|
||||
status: 'done',
|
||||
dueLabel: '6월 9일 완료',
|
||||
checklist: [3, 3],
|
||||
assignees: [USERS.kim],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '레퍼런스 리서치 및 무드보드 정리',
|
||||
status: 'done',
|
||||
dueLabel: '6월 6일 완료',
|
||||
assignees: [USERS.kim],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '퍼포먼스 광고 소재 세팅',
|
||||
status: 'done',
|
||||
dueLabel: '6월 5일 완료',
|
||||
checklist: [2, 2],
|
||||
assignees: [USERS.park],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '타깃 오디언스 정의 및 페르소나 정리',
|
||||
status: 'done',
|
||||
dueLabel: '6월 4일 완료',
|
||||
assignees: [USERS.park],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: '킥오프 미팅 및 일정 수립',
|
||||
status: 'done',
|
||||
dueLabel: '6월 2일 완료',
|
||||
assignees: [USERS.jung],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 업무 상세 (#9)
|
||||
* ============================================================ */
|
||||
export const TASK_DETAIL: TaskDetail = {
|
||||
id: 9,
|
||||
repoId: 'q3-launch-campaign',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
|
||||
status: 'review',
|
||||
statusLabel: '승인 대기',
|
||||
content: [
|
||||
'9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 @브랜드_가이드라인.pdf 기준을 따라주세요.',
|
||||
'퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.',
|
||||
],
|
||||
checklist: [
|
||||
{ text: '가로형 메인 배너 1920×1080 디자인', done: true },
|
||||
{ text: '세로형 메인 배너 1080×1920 디자인', done: false },
|
||||
{ text: '모바일 적응형 600×600 제작', done: false },
|
||||
],
|
||||
comments: [
|
||||
{
|
||||
author: USERS.jung,
|
||||
roleBadge: '지시자',
|
||||
time: '2일 전',
|
||||
text: '가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.',
|
||||
replies: [
|
||||
{ author: USERS.park, time: '2일 전', text: '네, 가로형 정렬 기준으로 먼저 잡겠습니다.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
author: USERS.park,
|
||||
time: '1일 전',
|
||||
text: '가로형 1차 시안 업로드했습니다. 검토 부탁드려요.',
|
||||
file: 'main_banner_h_v1.png',
|
||||
replies: [],
|
||||
},
|
||||
{
|
||||
author: USERS.choi,
|
||||
time: '3시간 전',
|
||||
text: '세로형은 내일 오전까지 공유하겠습니다.',
|
||||
replies: [],
|
||||
},
|
||||
],
|
||||
activities: [
|
||||
{
|
||||
tone: 'amber',
|
||||
icon: 'check-circle',
|
||||
html: '<b>박지훈</b>님이 승인을 요청했습니다',
|
||||
statusFrom: '진행 중',
|
||||
statusTo: '승인 대기',
|
||||
time: '2시간 전 · 오후 3:24',
|
||||
},
|
||||
{
|
||||
tone: 'green',
|
||||
icon: 'check',
|
||||
html: '<b>박지훈</b>님이 <b>가로형 메인 배너 1920×1080 디자인</b> 항목을 완료했습니다',
|
||||
time: '1일 전 · 오후 6:10',
|
||||
},
|
||||
{
|
||||
tone: '',
|
||||
icon: 'paperclip',
|
||||
html: '<b>박지훈</b>님이 파일을 첨부했습니다',
|
||||
file: 'main_banner_h_v1.png',
|
||||
time: '1일 전 · 오후 6:08',
|
||||
},
|
||||
{
|
||||
tone: 'blue',
|
||||
icon: 'calendar',
|
||||
html: '<b>정태경</b>님이 마감기한을 <b>6월 18일 (목)</b>로 설정했습니다',
|
||||
time: '6월 8일 · 오전 10:32',
|
||||
},
|
||||
{
|
||||
tone: 'blue',
|
||||
icon: 'users',
|
||||
html: '<b>정태경</b>님이 <b>박지훈</b>, <b>최유진</b>님을 담당자로 지정했습니다',
|
||||
time: '6월 8일 · 오전 10:31',
|
||||
},
|
||||
{
|
||||
tone: 'accent',
|
||||
icon: 'flag',
|
||||
html: '<b>정태경</b>님이 업무를 생성했습니다',
|
||||
time: '6월 8일 · 오전 10:30',
|
||||
},
|
||||
],
|
||||
assignees: [USERS.park, USERS.choi],
|
||||
issuer: USERS.jung,
|
||||
dueLabel: '6월 18일 (목)',
|
||||
dday: 'D-3',
|
||||
files: [
|
||||
{ name: '레퍼런스_무드보드.png', size: '3.2 MB', kind: 'img' },
|
||||
{ name: '배너_소스_파일.zip', size: '18.4 MB', kind: 'zip' },
|
||||
],
|
||||
approval: {
|
||||
requester: '박지훈',
|
||||
requestedAgo: '2시간 전',
|
||||
note: '가로형/세로형 메인 배너 1차 제작을 완료했습니다. 모바일 적응형은 컨펌 후 진행 예정이며, 우선 두 비율 검토 부탁드립니다.',
|
||||
},
|
||||
issuedLabel: '6월 8일 지시 · #9',
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 조회 헬퍼
|
||||
* ============================================================ */
|
||||
export function findRepo(id: string): Repo | undefined {
|
||||
return REPOS.find((r) => r.id === id)
|
||||
}
|
||||
|
||||
export function findRepoTasks(id: string): RepoTask[] {
|
||||
return REPO_TASKS[id] ?? []
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 내 업무 (담당 / 지시) — 상태 그룹 단위 정적 데이터
|
||||
* ============================================================ */
|
||||
|
||||
/** D-day 강조 단계 */
|
||||
export type DdayLevel = 'urgent' | 'soon' | 'calm'
|
||||
|
||||
/** 내 업무 목록 행 */
|
||||
export interface MyTaskRow {
|
||||
id: number
|
||||
title: string
|
||||
status: TaskStatus
|
||||
repoName: string
|
||||
/** 담당자 미니 아바타(지시 뷰) */
|
||||
assignees?: User[]
|
||||
/** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */
|
||||
metaText?: string
|
||||
/** metaText 를 amber 강조로 표시할지 (승인 요청 등) */
|
||||
metaAttn?: boolean
|
||||
/** 체크리스트 [완료, 전체] */
|
||||
checklist?: [number, number]
|
||||
/** 마감/완료 라벨(예: "6월 18일", "6월 9일 승인됨") */
|
||||
dueLabel?: string
|
||||
dday?: string
|
||||
ddayLevel?: DdayLevel
|
||||
/** 마감 지남(빨강 강조) */
|
||||
overdue?: boolean
|
||||
/** 배지 텍스트(예: 진행 중 / 승인 대기). reviewBtn 이 true 면 미표시 */
|
||||
statusLabel?: string
|
||||
/** 검토하기 버튼 표시(지시 뷰의 승인 대기) */
|
||||
reviewBtn?: boolean
|
||||
/** 완료 행 스타일 */
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
/** 상태 그룹 */
|
||||
export interface MyTaskGroup {
|
||||
key: TaskStatus
|
||||
label: string
|
||||
/** 그룹 헤더 우측 보조 문구(예: "· 내 승인 필요") */
|
||||
action?: string
|
||||
/** 강조 목록 스타일(amber 테두리) */
|
||||
attn?: boolean
|
||||
rows: MyTaskRow[]
|
||||
}
|
||||
|
||||
/** 담당 업무 */
|
||||
export const MY_TASKS_ASSIGNED: MyTaskGroup[] = [
|
||||
{
|
||||
key: 'changes',
|
||||
label: '수정 요청',
|
||||
rows: [
|
||||
{
|
||||
id: 14,
|
||||
title: '3분기 캠페인 예산안 재작성',
|
||||
status: 'changes',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 16일',
|
||||
dday: 'D-1',
|
||||
ddayLevel: 'urgent',
|
||||
statusLabel: '수정 요청',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'prog',
|
||||
label: '진행 중',
|
||||
rows: [
|
||||
{
|
||||
id: 12,
|
||||
title: '브랜드 리뉴얼 킥오프 기획서 작성',
|
||||
status: 'prog',
|
||||
repoName: '브랜드 리뉴얼 2026',
|
||||
checklist: [2, 5],
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 18일',
|
||||
dday: 'D-3',
|
||||
ddayLevel: 'soon',
|
||||
statusLabel: '진행 중',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: '9월 신제품 GTM 전략 수립',
|
||||
status: 'prog',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
checklist: [1, 4],
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 21일',
|
||||
dday: 'D-6',
|
||||
ddayLevel: 'calm',
|
||||
statusLabel: '진행 중',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
label: '할 일',
|
||||
rows: [
|
||||
{
|
||||
id: 15,
|
||||
title: '월간 뉴스레터 8월호 주제 선정',
|
||||
status: 'todo',
|
||||
repoName: '월간 뉴스레터',
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 24일',
|
||||
dday: 'D-9',
|
||||
ddayLevel: 'calm',
|
||||
statusLabel: '할 일',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'SEO 키워드 전략 분기 리뷰',
|
||||
status: 'todo',
|
||||
repoName: 'SEO 콘텐츠 허브',
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 27일',
|
||||
dday: 'D-12',
|
||||
ddayLevel: 'calm',
|
||||
statusLabel: '할 일',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '승인 대기',
|
||||
rows: [
|
||||
{
|
||||
id: 2,
|
||||
title: '브랜드 무드보드 방향 정리',
|
||||
status: 'review',
|
||||
repoName: '브랜드 리뉴얼 2026',
|
||||
metaText: '한승우님 검토 중 · 1일 전 요청',
|
||||
dueLabel: '6월 17일',
|
||||
dday: 'D-2',
|
||||
ddayLevel: 'soon',
|
||||
statusLabel: '승인 대기',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: '완료',
|
||||
rows: [
|
||||
{
|
||||
id: 6,
|
||||
title: '2분기 캠페인 성과 회고 정리',
|
||||
status: 'done',
|
||||
repoName: '월간 뉴스레터',
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 11일 승인됨',
|
||||
statusLabel: '완료',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '신제품 네이밍 후보 정리',
|
||||
status: 'done',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
metaText: '지시 · 한승우',
|
||||
dueLabel: '6월 5일 승인됨',
|
||||
statusLabel: '완료',
|
||||
done: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 지시한 업무 */
|
||||
export const MY_TASKS_ISSUED: MyTaskGroup[] = [
|
||||
{
|
||||
key: 'review',
|
||||
label: '승인 대기',
|
||||
action: '· 내 승인 필요',
|
||||
attn: true,
|
||||
rows: [
|
||||
{
|
||||
id: 9,
|
||||
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
|
||||
status: 'review',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.park, USERS.choi],
|
||||
metaText: '박지훈님이 2시간 전 승인 요청',
|
||||
metaAttn: true,
|
||||
dueLabel: '6월 18일',
|
||||
dday: 'D-3',
|
||||
ddayLevel: 'soon',
|
||||
reviewBtn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'prog',
|
||||
label: '진행 중',
|
||||
rows: [
|
||||
{
|
||||
id: 11,
|
||||
title: '법무 검토 요청 및 회신 반영',
|
||||
status: 'prog',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.lee],
|
||||
metaText: '이도윤',
|
||||
dueLabel: '6월 12일',
|
||||
dday: '3일 지남',
|
||||
ddayLevel: 'urgent',
|
||||
overdue: true,
|
||||
statusLabel: '진행 중',
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
title: 'SNS 콘텐츠 2차 시안 제작',
|
||||
status: 'prog',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.han],
|
||||
metaText: '정민호',
|
||||
checklist: [3, 6],
|
||||
dueLabel: '6월 19일',
|
||||
dday: 'D-4',
|
||||
ddayLevel: 'soon',
|
||||
statusLabel: '진행 중',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'changes',
|
||||
label: '수정 요청함',
|
||||
rows: [
|
||||
{
|
||||
id: 8,
|
||||
title: '상세페이지 카피라이팅',
|
||||
status: 'changes',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.lee],
|
||||
metaText: '이도윤에게 수정 요청 · 1일 전',
|
||||
dueLabel: '6월 20일',
|
||||
dday: 'D-5',
|
||||
ddayLevel: 'soon',
|
||||
statusLabel: '수정 요청',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
label: '할 일',
|
||||
rows: [
|
||||
{
|
||||
id: 7,
|
||||
title: 'SNS 콘텐츠 1차 시안',
|
||||
status: 'todo',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.han],
|
||||
metaText: '정민호',
|
||||
dueLabel: '6월 23일',
|
||||
dday: 'D-8',
|
||||
ddayLevel: 'calm',
|
||||
statusLabel: '할 일',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: '완료',
|
||||
rows: [
|
||||
{
|
||||
id: 6,
|
||||
title: '캠페인 핵심 메시지 시안 작성',
|
||||
status: 'done',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.kim],
|
||||
metaText: '김서연',
|
||||
dueLabel: '6월 9일 승인됨',
|
||||
statusLabel: '완료',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '퍼포먼스 광고 소재 세팅',
|
||||
status: 'done',
|
||||
repoName: '3분기 신제품 런칭 캠페인',
|
||||
assignees: [USERS.park],
|
||||
metaText: '박지훈',
|
||||
dueLabel: '6월 5일 승인됨',
|
||||
statusLabel: '완료',
|
||||
done: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 비완료(활성) 업무 수 — 뷰 탭 카운트용 */
|
||||
export function countActive(groups: MyTaskGroup[]): number {
|
||||
return groups.filter((g) => g.key !== 'done').reduce((sum, g) => sum + g.rows.length, 0)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 저장소 멤버
|
||||
* ============================================================ */
|
||||
export interface RepoMember {
|
||||
user: User
|
||||
email: string
|
||||
/** 저장소 내 직무 표기(예: 리드, 콘텐츠 디자인) */
|
||||
subRole: string
|
||||
/** 담당 업무 수 */
|
||||
taskCount: number
|
||||
/** 권한 역할 */
|
||||
roleType: 'admin' | 'member'
|
||||
/** 현재 로그인 사용자 여부 */
|
||||
isYou?: boolean
|
||||
/** 소유자(역할 변경/제거 불가) */
|
||||
owner?: boolean
|
||||
}
|
||||
|
||||
export const REPO_MEMBERS: Record<string, RepoMember[]> = {
|
||||
'q3-launch-campaign': [
|
||||
{ user: USERS.jung, email: 'tkjung@acme.co', subRole: '리드', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
|
||||
{ user: USERS.kim, email: 'sykim@acme.co', subRole: '콘텐츠 디자인', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.park, email: 'jhpark@acme.co', subRole: '퍼포먼스 마케팅', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.lee, email: 'dylee@acme.co', subRole: '카피라이터', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.choi, email: 'yjchoi@acme.co', subRole: 'UX 디자인', taskCount: 1, roleType: 'member' },
|
||||
],
|
||||
}
|
||||
|
||||
export function findRepoMembers(id: string): RepoMember[] {
|
||||
return REPO_MEMBERS[id] ?? []
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 저장소 활동 피드
|
||||
* ============================================================ */
|
||||
/** 활동 아이콘 종류(모양) */
|
||||
export type ActivityIcon =
|
||||
| 'check'
|
||||
| 'pencil'
|
||||
| 'member-add'
|
||||
| 'member-check'
|
||||
| 'member-remove'
|
||||
| 'lock'
|
||||
| 'repo'
|
||||
|
||||
/** 활동 항목 */
|
||||
export interface RepoActivityItem {
|
||||
/** 아이콘 색상 톤 */
|
||||
tone: 'task' | 'member' | 'setting'
|
||||
/** 아이콘 모양 */
|
||||
icon: ActivityIcon
|
||||
/** 행위자 아바타 */
|
||||
initial: string
|
||||
color: AvatarColor
|
||||
/** 본문 HTML(내부 생성, 신뢰 가능 마크업) */
|
||||
html: string
|
||||
time: string
|
||||
}
|
||||
|
||||
/** 날짜 그룹 */
|
||||
export interface RepoActivityDay {
|
||||
day: string
|
||||
items: RepoActivityItem[]
|
||||
}
|
||||
|
||||
export const REPO_ACTIVITY: RepoActivityDay[] = [
|
||||
{
|
||||
day: '오늘',
|
||||
items: [
|
||||
{
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
initial: '이',
|
||||
color: 'av-c',
|
||||
html: '<b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다',
|
||||
time: '2시간 전',
|
||||
},
|
||||
{
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
initial: '박',
|
||||
color: 'av-b',
|
||||
html: '<b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다',
|
||||
time: '3시간 전',
|
||||
},
|
||||
{
|
||||
tone: 'member',
|
||||
icon: 'member-add',
|
||||
initial: '정',
|
||||
color: 'av-f',
|
||||
html: '<b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다',
|
||||
time: '5시간 전',
|
||||
},
|
||||
{
|
||||
tone: 'member',
|
||||
icon: 'member-check',
|
||||
initial: '한',
|
||||
color: 'av-d',
|
||||
html: '<b>한지민</b>님이 멤버로 합류했습니다',
|
||||
time: '5시간 전',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
day: '어제',
|
||||
items: [
|
||||
{
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
initial: '최',
|
||||
color: 'av-d',
|
||||
html: '<b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다',
|
||||
time: '어제 18:40',
|
||||
},
|
||||
{
|
||||
tone: 'task',
|
||||
icon: 'pencil',
|
||||
initial: '정',
|
||||
color: 'av-f',
|
||||
html: '<b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span>',
|
||||
time: '어제 14:10',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
day: '이번 주',
|
||||
items: [
|
||||
{
|
||||
tone: 'setting',
|
||||
icon: 'lock',
|
||||
initial: '정',
|
||||
color: 'av-f',
|
||||
html: '<b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다',
|
||||
time: '6월 11일',
|
||||
},
|
||||
{
|
||||
tone: 'member',
|
||||
icon: 'member-remove',
|
||||
initial: '박',
|
||||
color: 'av-b',
|
||||
html: '<b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다',
|
||||
time: '6월 11일',
|
||||
},
|
||||
{
|
||||
tone: 'setting',
|
||||
icon: 'pencil',
|
||||
initial: '정',
|
||||
color: 'av-f',
|
||||
html: '<b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다',
|
||||
time: '6월 10일',
|
||||
},
|
||||
{
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
initial: '김',
|
||||
color: 'av-a',
|
||||
html: '<b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다',
|
||||
time: '6월 9일',
|
||||
},
|
||||
{
|
||||
tone: 'setting',
|
||||
icon: 'repo',
|
||||
initial: '정',
|
||||
color: 'av-f',
|
||||
html: '<b>정태경</b>님이 저장소를 생성했습니다',
|
||||
time: '6월 2일',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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
@@ -0,0 +1,119 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/repos',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/relay/LoginPage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'signup',
|
||||
component: () => import('@/pages/relay/SignupPage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/repos',
|
||||
name: 'repo-list',
|
||||
component: () => import('@/pages/relay/RepoListPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 정적 경로 '/repos/new' 는 동적 '/repos/:repoId' 보다 먼저 선언해 우선 매칭되게 한다
|
||||
path: '/repos/new',
|
||||
name: 'repo-create',
|
||||
component: () => import('@/pages/relay/RepoCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId',
|
||||
name: 'repo-detail',
|
||||
component: () => import('@/pages/relay/RepoDetailPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/members',
|
||||
name: 'repo-members',
|
||||
component: () => import('@/pages/relay/RepoMembersPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/activity',
|
||||
name: 'repo-activity',
|
||||
component: () => import('@/pages/relay/RepoActivityPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/settings',
|
||||
name: 'repo-settings',
|
||||
component: () => import('@/pages/relay/RepoSettingsPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/tasks/new',
|
||||
name: 'task-create',
|
||||
component: () => import('@/pages/relay/TaskCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/tasks/:taskId',
|
||||
name: 'task-detail',
|
||||
component: () => import('@/pages/relay/TaskDetailPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 담당자(작업자) 관점의 업무 상세 — ?state=request|changes|approved
|
||||
path: '/repos/:repoId/tasks/:taskId/view',
|
||||
name: 'task-assignee-view',
|
||||
component: () => import('@/pages/relay/TaskAssigneePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// '내 업무' — 다음 단계에서 구현 예정(현재는 임시 안내)
|
||||
path: '/tasks',
|
||||
name: 'my-tasks',
|
||||
component: () => import('@/pages/relay/MyTasksPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/pages/NotFoundPage.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 네비게이션 가드 — 세션 부트스트랩 + 인증 분기
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 앱 최초 진입 시 1회 세션 복원(/auth/me). 이후는 캐시된 상태 사용
|
||||
if (!authStore.bootstrapped) {
|
||||
await authStore.bootstrap()
|
||||
}
|
||||
|
||||
// 보호 라우트인데 미로그인 → 로그인 페이지로 (원래 목적지를 redirect 쿼리로 보존)
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 저장소 목록으로
|
||||
if (!to.meta.requiresAuth && authStore.isLoggedIn && (to.name === 'login' || to.name === 'signup')) {
|
||||
return { path: '/repos' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,77 @@
|
||||
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
|
||||
|
||||
/**
|
||||
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
return `${value.toLocaleString('ko-KR')}원`
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
|
||||
*/
|
||||
export function formatDate(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 표시용 파생 값 — 백엔드 원시 데이터를 화면 라벨/색상으로 변환
|
||||
* (서버는 타임스탬프/카운트만 내려주고 여기서 계산한다)
|
||||
* ============================================================ */
|
||||
|
||||
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
|
||||
export type AvatarColorKey = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
|
||||
|
||||
const AVATAR_COLORS: AvatarColorKey[] = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e', 'av-f']
|
||||
|
||||
/** 식별자(문자열)를 결정적으로 아바타 색상에 매핑 */
|
||||
export function avatarColor(seed: string): AvatarColorKey {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? 'av-a'
|
||||
}
|
||||
|
||||
/** 이름의 첫 글자(아바타 이니셜) */
|
||||
export function initialOf(name: string): string {
|
||||
return name.trim().charAt(0) || '?'
|
||||
}
|
||||
|
||||
/** ISO 문자열/Date 를 한국어 상대시간으로 (예: "2시간 전", "어제", "3일 전") */
|
||||
export function relativeTimeKo(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
const min = Math.floor(diffMs / 60_000)
|
||||
if (min < 1) return '방금'
|
||||
if (min < 60) return `${min}분 전`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}시간 전`
|
||||
const day = Math.floor(hour / 24)
|
||||
if (day === 1) return '어제'
|
||||
if (day < 7) return `${day}일 전`
|
||||
const week = Math.floor(day / 7)
|
||||
if (week < 5) return `${week}주 전`
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
/** 진행률 정보 (완료/전체 → 퍼센트·단계·라벨) */
|
||||
export interface ProgressInfo {
|
||||
pct: number
|
||||
level: 'done' | 'mid' | 'low'
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 완료/전체 수로 진행률 계산 */
|
||||
export function progressOf(done: number, total: number): ProgressInfo {
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
const level: ProgressInfo['level'] = pct >= 90 ? 'done' : pct >= 40 ? 'mid' : 'low'
|
||||
const label = pct === 0 ? '시작 전' : `${pct}% 완료`
|
||||
return { pct, level, label }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
|
||||
|
||||
// 인증 상태 스토어 — 현재 사용자/로그인 여부 + 인증 액션
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null)
|
||||
// 앱 시작 시 1회 부트스트랩(/auth/me) 완료 여부 — 가드에서 중복 호출 방지
|
||||
const bootstrapped = ref<boolean>(false)
|
||||
|
||||
const isLoggedIn = computed<boolean>(() => user.value !== null)
|
||||
|
||||
const { signup: signupApi, login: loginApi, logout: logoutApi, fetchMe } = useAuth()
|
||||
|
||||
// 로그인
|
||||
async function login(payload: LoginPayload): Promise<void> {
|
||||
user.value = await loginApi(payload)
|
||||
}
|
||||
|
||||
// 회원가입 (성공 시 자동 로그인 상태)
|
||||
async function signup(payload: SignupPayload): Promise<void> {
|
||||
user.value = await signupApi(payload)
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await logoutApi()
|
||||
} finally {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 앱 시작 시 세션 복원 — 쿠키 기반으로 현재 사용자 조회. 실패 시 비로그인 상태
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (bootstrapped.value) return
|
||||
try {
|
||||
user.value = await fetchMe()
|
||||
} catch {
|
||||
user.value = null
|
||||
} finally {
|
||||
bootstrapped.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, bootstrapped, login, signup, logout, bootstrap }
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import { avatarColor, initialOf, progressOf, relativeTimeKo } from '@/shared/utils/format'
|
||||
import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock'
|
||||
|
||||
// 아바타 스택에 노출할 멤버 수(초과분은 +N 으로 표기)
|
||||
const AVATAR_LIMIT = 3
|
||||
|
||||
// API 멤버 → 화면용 User(이니셜/색상 파생)
|
||||
function toUserView(member: ApiMember): UserView {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
role: member.role ?? '',
|
||||
initial: initialOf(member.name),
|
||||
color: avatarColor(member.id),
|
||||
}
|
||||
}
|
||||
|
||||
// API 저장소 → 화면용 Repo(진행률/상대시간/아바타 파생)
|
||||
function toRepoView(api: ApiRepo): RepoView {
|
||||
const prog = progressOf(api.doneCount, api.totalCount)
|
||||
const shown = api.members.slice(0, AVATAR_LIMIT).map(toUserView)
|
||||
return {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
owner: api.owner,
|
||||
slug: api.slug,
|
||||
desc: api.desc ?? '',
|
||||
visibility: api.visibility,
|
||||
branch: api.branch,
|
||||
members: shown,
|
||||
moreCount: Math.max(0, api.memberCount - shown.length),
|
||||
updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`,
|
||||
doneCount: api.doneCount,
|
||||
totalCount: api.totalCount,
|
||||
progressPct: prog.pct,
|
||||
progressLevel: prog.level,
|
||||
progressLabel: prog.label,
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 스토어 — 목록/현재 저장소 상태 + CRUD 액션
|
||||
export const useRepoStore = defineStore('repo', () => {
|
||||
const repos = ref<RepoView[]>([])
|
||||
const current = ref<RepoView | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 목록 조회
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await repoApi.list()
|
||||
repos.value = list.map(toRepoView)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 조회
|
||||
async function fetchOne(id: string): Promise<RepoView | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
current.value = toRepoView(await repoApi.get(id))
|
||||
return current.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 생성
|
||||
async function create(payload: CreateRepoPayload): Promise<RepoView> {
|
||||
return toRepoView(await repoApi.create(payload))
|
||||
}
|
||||
|
||||
// 수정
|
||||
async function update(id: string, payload: UpdateRepoPayload): Promise<RepoView> {
|
||||
const view = toRepoView(await repoApi.update(id, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function remove(id: string): Promise<void> {
|
||||
await repoApi.remove(id)
|
||||
repos.value = repos.value.filter((r) => r.id !== id)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return { repos, current, loading, fetchList, fetchOne, create, update, remove }
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// 인증 사용자 — 백엔드 PublicUser(/auth/me, /auth/login 응답)와 1:1 대응
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string | null
|
||||
}
|
||||
|
||||
// 로그인 요청 페이로드
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
keepLogin?: boolean
|
||||
}
|
||||
|
||||
// 회원가입 요청 페이로드
|
||||
export interface SignupPayload {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
role?: string
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
// 저장소 멤버(API 원시) — PublicUser 와 동일
|
||||
export interface ApiMember {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string | null
|
||||
}
|
||||
|
||||
// 저장소(API 원시) — 백엔드 RepoResponse 와 1:1
|
||||
export interface ApiRepo {
|
||||
id: string // slugName (라우팅 식별자)
|
||||
name: string
|
||||
owner: string
|
||||
slug: string // owner/slugName
|
||||
desc: string | null
|
||||
visibility: Visibility
|
||||
branch: string
|
||||
members: ApiMember[]
|
||||
memberCount: number
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 저장소 생성 요청
|
||||
export interface CreateRepoPayload {
|
||||
slug: string // 영문 저장소 이름
|
||||
name: string // 표시 제목(한글)
|
||||
visibility: Visibility
|
||||
description?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
// 저장소 수정 요청
|
||||
export interface UpdateRepoPayload {
|
||||
name?: string
|
||||
description?: string
|
||||
visibility?: Visibility
|
||||
branch?: string
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
|
||||
// Override to include only test files and clear exclusions.
|
||||
// Application code imported in tests is automatically included via module resolution.
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
|
||||
"compilerOptions": {
|
||||
// Vitest runs in a different environment than the application code.
|
||||
// Adjust lib and types accordingly.
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"],
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
|
||||
allowedHosts: true,
|
||||
// 도커/윈도우 환경 파일 핫 리로딩 강제 활성화
|
||||
watch: {
|
||||
usePolling: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user