init source
This commit is contained in:
94
.dockerignore
Normal file
94
.dockerignore
Normal file
@@ -0,0 +1,94 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
*.log
|
||||
package-lock.json
|
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# nodejs-18-alpine 이미지를 기반으로 사용 (가볍고 효율적)
|
||||
FROM node:20-alpine
|
||||
|
||||
# 컨테이너의 작업 디렉터리를 설정
|
||||
WORKDIR /app
|
||||
|
||||
# package.json과 package-lock.json을 복사
|
||||
COPY package*.json ./
|
||||
|
||||
# 의존성 설치
|
||||
RUN npm install
|
||||
|
||||
# 모든 소스 파일을 컨테이너로 복사
|
||||
COPY . .
|
||||
|
||||
# 개발 환경 실행 명령 (이 명령은 docker-compose.yml에서 덮어쓸 것임)
|
||||
CMD ["npm", "run", "dev"]
|
127
README-Docker.md
Normal file
127
README-Docker.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Docker 배포 가이드
|
||||
|
||||
이 프로젝트는 Docker를 사용하여 컨테이너화할 수 있습니다.
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker Desktop 설치
|
||||
- Docker Compose 설치 (Docker Desktop에 포함됨)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. 도커 이미지 빌드 및 실행
|
||||
|
||||
```bash
|
||||
# 프로젝트 루트 디렉토리에서
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### 2. 백그라운드에서 실행
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 3. 컨테이너 중지
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 수동 도커 명령어
|
||||
|
||||
### 이미지 빌드
|
||||
|
||||
```bash
|
||||
docker build -t nuxt-app .
|
||||
```
|
||||
|
||||
### 컨테이너 실행
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 nuxt-app
|
||||
```
|
||||
|
||||
### 백그라운드에서 실행
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 --name nuxt-container nuxt-app
|
||||
```
|
||||
|
||||
## 환경 변수 설정
|
||||
|
||||
필요한 경우 환경 변수를 설정할 수 있습니다:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e API_URL=https://api.example.com \
|
||||
nuxt-app
|
||||
```
|
||||
|
||||
또는 `.env` 파일을 사용:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 --env-file .env nuxt-app
|
||||
```
|
||||
|
||||
## 프로덕션 배포
|
||||
|
||||
### 1. 이미지 태그 지정
|
||||
|
||||
```bash
|
||||
docker build -t your-registry/nuxt-app:latest .
|
||||
```
|
||||
|
||||
### 2. 이미지 푸시
|
||||
|
||||
```bash
|
||||
docker push your-registry/nuxt-app:latest
|
||||
```
|
||||
|
||||
### 3. 프로덕션 서버에서 실행
|
||||
|
||||
```bash
|
||||
docker pull your-registry/nuxt-app:latest
|
||||
docker run -d -p 3000:3000 your-registry/nuxt-app:latest
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 포트 충돌
|
||||
|
||||
다른 포트를 사용하려면:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:3000 nuxt-app
|
||||
```
|
||||
|
||||
### 볼륨 마운트 (개발용)
|
||||
|
||||
소스 코드 변경사항을 실시간으로 반영하려면:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v $(pwd):/app nuxt-app
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
```bash
|
||||
# docker-compose 사용 시
|
||||
docker-compose logs
|
||||
|
||||
# 수동 실행 시
|
||||
docker logs <container-id>
|
||||
```
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
- 프로덕션에서는 non-root 사용자로 실행됩니다
|
||||
- 필요한 포트만 노출됩니다
|
||||
- 민감한 정보는 환경 변수로 관리하세요
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
- 멀티스테이지 빌드를 사용하여 이미지 크기를 최소화했습니다
|
||||
- 프로덕션 의존성만 설치합니다
|
||||
- 캐시를 정리하여 이미지 크기를 줄입니다
|
24
app.vue
Normal file
24
app.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "./assets/css/main.css";
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
onMounted(() => {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/dist/cy_custom.js'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
console.log('✅ cy_custom.js loaded')
|
||||
}
|
||||
script.onerror = () => {
|
||||
console.error('❌ Failed to load cy_custom.js')
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
</script>
|
78
assets/css/main.css
Normal file
78
assets/css/main.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 기본 CSS 스타일 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 프로젝트 스타일 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-5 {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* 반응형 스타일 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
88
components/AppButton.vue
Normal file
88
components/AppButton.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<button
|
||||
:class="['app-button', variant, size]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
size?: "small" | "medium" | "large";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
click: [];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: "primary",
|
||||
size: "medium",
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-button {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.primary {
|
||||
background-color: #00dc82;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background-color: #00b368;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
</style>
|
237
components/AppHeader.vue
Normal file
237
components/AppHeader.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<header
|
||||
class="w-full bg-white shadow flex items-center justify-center px-4 h-24 relative"
|
||||
>
|
||||
<nav class="flex justify-center space-x-4">
|
||||
<!-- HOME 메뉴 -->
|
||||
<button
|
||||
class="menu-btn"
|
||||
:class="{ active: modelValue === 'home' }"
|
||||
@click="$emit('update:modelValue', 'home')"
|
||||
>
|
||||
HOME
|
||||
</button>
|
||||
<!-- 테스트트 메뉴 -->
|
||||
<button
|
||||
class="menu-btn"
|
||||
:class="{ active: modelValue === 'test' }"
|
||||
@click="$emit('update:modelValue', 'test')"
|
||||
>
|
||||
테스트 메뉴
|
||||
</button>
|
||||
<!-- 관리자 메뉴 (관리자만 표시) -->
|
||||
<button
|
||||
v-if="userStore.isAdmin"
|
||||
class="menu-btn"
|
||||
:class="{ active: modelValue === 'admin' }"
|
||||
@click="$emit('update:modelValue', 'admin')"
|
||||
>
|
||||
관리자 메뉴
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 사용자 정보 및 드롭다운 -->
|
||||
<div class="user-menu-wrapper">
|
||||
<div class="user-info" @click="toggleDropdown">
|
||||
<span class="user-name">{{ userStore.userName }}</span>
|
||||
<div class="user-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#222"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-2.5 3.5-4 8-4s8 1.5 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showDropdown" class="user-dropdown">
|
||||
<div class="user-details">
|
||||
<p class="user-email">{{ userStore.user?.email }}</p>
|
||||
<p class="user-role">{{ userStore.isAdmin ? "관리자" : "사용자" }}</p>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="logout-btn" @click="logout">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16,17 21,12 16,7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["update:modelValue"]);
|
||||
|
||||
const showDropdown = ref(false);
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
function toggleDropdown() {
|
||||
showDropdown.value = !showDropdown.value;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
const menu = document.querySelector(".user-menu-wrapper");
|
||||
if (menu && !menu.contains(event.target)) {
|
||||
showDropdown.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userStore.logout();
|
||||
showDropdown.value = false;
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-btn {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn.active {
|
||||
background: none;
|
||||
color: #1976d2;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: #e6f0fa;
|
||||
color: #1976d2;
|
||||
}
|
||||
.group:hover .group-hover\:opacity-100 {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.group-hover\:pointer-events-auto {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.user-menu-wrapper {
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.user-info:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
.user-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.user-details {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.user-email {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.user-role {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dropdown-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #e0e0e0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #d32f2f;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.logout-btn:hover {
|
||||
background: #fbe9e7;
|
||||
}
|
||||
</style>
|
651
components/BatchGraph.vue
Normal file
651
components/BatchGraph.vue
Normal file
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<div class="graph-container">
|
||||
<div class="echarts-container">
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" title="확대" @click="zoomIn">
|
||||
<span>+</span>
|
||||
</button>
|
||||
<button class="zoom-btn" title="축소" @click="zoomOut">
|
||||
<span>−</span>
|
||||
</button>
|
||||
<button class="zoom-btn reset" title="초기화" @click="resetZoom">
|
||||
<span>⟲</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="growthChartRef" class="echarts-graph"></div>
|
||||
<div class="yaxis-scroll-bar-overlay">
|
||||
<input
|
||||
v-model.number="yAxisScrollIndex"
|
||||
type="range"
|
||||
min="0"
|
||||
:max="Math.max(0, filteredYAxisList.length - 2)"
|
||||
:disabled="filteredYAxisList.length <= 2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
const props = defineProps<{ batchIndex: number }>();
|
||||
|
||||
// --- 기존 culture-graph.vue에서 필요한 상수/데이터 복사 ---
|
||||
const pastelColors = [
|
||||
"#A3D8F4",
|
||||
"#F7B7A3",
|
||||
"#B5EAD7",
|
||||
"#FFDAC1",
|
||||
"#C7CEEA",
|
||||
"#FFF1BA",
|
||||
"#FFB7B2",
|
||||
"#B4A7D6",
|
||||
"#AED9E0",
|
||||
"#FFC3A0",
|
||||
"#E2F0CB",
|
||||
"#FFB347",
|
||||
"#C1C8E4",
|
||||
"#FFFACD",
|
||||
"#FFD1DC",
|
||||
];
|
||||
const yAxisList = [
|
||||
{
|
||||
name: "ORP",
|
||||
unit: "",
|
||||
color: pastelColors[0],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
ticks: [1000, 800, 600, 400, 200, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "Air flow",
|
||||
unit: "(L/min)",
|
||||
color: pastelColors[1],
|
||||
min: 0,
|
||||
max: 30,
|
||||
ticks: [30, 25, 20, 15, 10, 5, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "DO",
|
||||
unit: "",
|
||||
color: pastelColors[2],
|
||||
min: 0,
|
||||
max: 200,
|
||||
ticks: [200, 150, 100, 50, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "Feed TK1",
|
||||
unit: "(L)",
|
||||
color: pastelColors[3],
|
||||
min: 0,
|
||||
max: 10,
|
||||
ticks: [10, 8, 6, 4, 2, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "Feed TK2",
|
||||
unit: "(L)",
|
||||
color: pastelColors[4],
|
||||
min: 0,
|
||||
max: 10,
|
||||
ticks: [10, 8, 6, 4, 2, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "pH",
|
||||
unit: "",
|
||||
color: pastelColors[5],
|
||||
min: 6.0,
|
||||
max: 8.0,
|
||||
ticks: [8.0, 7.5, 7.0, 6.5, 6.0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "Pressure",
|
||||
unit: "(bar)",
|
||||
color: pastelColors[6],
|
||||
min: 0,
|
||||
max: 2,
|
||||
ticks: [2, 1.5, 1, 0.5, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "RPM",
|
||||
unit: "",
|
||||
color: pastelColors[7],
|
||||
min: 0,
|
||||
max: 3000,
|
||||
ticks: [3000, 2400, 1800, 1200, 600, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "CO2",
|
||||
unit: "(%)",
|
||||
color: pastelColors[8],
|
||||
min: 0,
|
||||
max: 10,
|
||||
ticks: [10, 8, 6, 4, 2, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "JAR Vol",
|
||||
unit: "(L)",
|
||||
color: pastelColors[9],
|
||||
min: 0,
|
||||
max: 20,
|
||||
ticks: [20, 16, 12, 8, 4, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "WEIGHT",
|
||||
unit: "(kg)",
|
||||
color: pastelColors[10],
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: [100, 80, 60, 40, 20, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "O2",
|
||||
unit: "(%)",
|
||||
color: pastelColors[11],
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: [100, 80, 60, 40, 20, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "NV",
|
||||
unit: "",
|
||||
color: pastelColors[12],
|
||||
min: 0,
|
||||
max: 10,
|
||||
ticks: [10, 8, 6, 4, 2, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "NIR",
|
||||
unit: "",
|
||||
color: pastelColors[13],
|
||||
min: 0,
|
||||
max: 10,
|
||||
ticks: [10, 8, 6, 4, 2, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
name: "Temperature",
|
||||
unit: "(℃)",
|
||||
color: pastelColors[14],
|
||||
min: 0,
|
||||
max: 50,
|
||||
ticks: [50, 40, 30, 20, 10, 0],
|
||||
fontWeight: "bold",
|
||||
},
|
||||
];
|
||||
|
||||
const intervalValue = ref(60); // 1분
|
||||
const totalSeconds = 100 * 60 * 60;
|
||||
const pointsPerLine = computed(
|
||||
() => Math.floor(totalSeconds / intervalValue.value) + 1
|
||||
);
|
||||
const xLabels = computed(() =>
|
||||
Array.from({ length: pointsPerLine.value }, (_, i) => i * intervalValue.value)
|
||||
);
|
||||
|
||||
// --- batchIndex에 따라 곡선 패턴이 다르게 나오도록 smoothData 수정 ---
|
||||
function smoothData(
|
||||
min: number,
|
||||
max: number,
|
||||
xLabels: number[],
|
||||
phase = 0,
|
||||
_amp = 1,
|
||||
offset = 0,
|
||||
seriesIndex: number
|
||||
) {
|
||||
let _prevValue = 0.5;
|
||||
const values = [];
|
||||
// batchIndex를 곡선 패턴에 반영 (phase, ampVar, randomFactor 등)
|
||||
const batchPhase = phase + (props.batchIndex * Math.PI) / 5;
|
||||
const ampVar = 0.2 + ((seriesIndex + props.batchIndex) % 5) * 0.1;
|
||||
const randomFactor = 0.01 + 0.01 * ((seriesIndex + props.batchIndex) % 4);
|
||||
const trendType = (seriesIndex + props.batchIndex) % 4;
|
||||
const rangeMin = min + (max - min) * 0.1;
|
||||
const rangeMax = max - (max - min) * 0.1;
|
||||
for (let i = 0; i < xLabels.length; i++) {
|
||||
const t = i / (xLabels.length - 1);
|
||||
let base;
|
||||
if (trendType === 0) {
|
||||
base =
|
||||
0.2 + 0.6 * t + 0.13 * Math.sin(batchPhase + t * Math.PI * ampVar * 8);
|
||||
} else if (trendType === 1) {
|
||||
base =
|
||||
0.8 - 0.6 * t + 0.13 * Math.cos(batchPhase + t * Math.PI * ampVar * 8);
|
||||
} else if (trendType === 2) {
|
||||
base = 0.5 + 0.22 * Math.sin(batchPhase + t * Math.PI * ampVar * 12);
|
||||
} else {
|
||||
base =
|
||||
0.5 + 0.08 * Math.sin(batchPhase + t * Math.PI * ampVar * 6) + 0.2 * t;
|
||||
}
|
||||
// 노이즈도 batchIndex에 따라 다르게
|
||||
const noise =
|
||||
(Math.sin(i + props.batchIndex * 13) * 0.5 + 0.5) *
|
||||
randomFactor *
|
||||
(Math.random() - 0.5);
|
||||
base += noise;
|
||||
base = Math.max(0, Math.min(1, base));
|
||||
const value = +(rangeMin + (rangeMax - rangeMin) * base + offset).toFixed(
|
||||
2
|
||||
);
|
||||
_prevValue = base;
|
||||
values.push([xLabels[i], value]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const checkedList = ref(yAxisList.map(y => y.name));
|
||||
const filteredYAxisList = computed(() =>
|
||||
yAxisList.filter(y => checkedList.value.includes(y.name))
|
||||
);
|
||||
const filteredSeriesList = computed(() =>
|
||||
seriesList.value.filter(s => checkedList.value.includes(s.name))
|
||||
);
|
||||
const yAxisScrollIndex = ref(0);
|
||||
const visibleYAxisList = computed(() =>
|
||||
filteredYAxisList.value.slice(
|
||||
yAxisScrollIndex.value,
|
||||
yAxisScrollIndex.value + 2
|
||||
)
|
||||
);
|
||||
|
||||
const seriesList = computed(() => [
|
||||
{
|
||||
name: "ORP",
|
||||
color: pastelColors[0],
|
||||
yAxisIndex: 0,
|
||||
data: smoothData(400, 900, xLabels.value, 0, 1, 0, 0),
|
||||
},
|
||||
{
|
||||
name: "Air flow",
|
||||
color: pastelColors[1],
|
||||
yAxisIndex: 1,
|
||||
data: smoothData(5, 25, xLabels.value, Math.PI / 2, 1, 0, 1),
|
||||
},
|
||||
{
|
||||
name: "DO",
|
||||
color: pastelColors[2],
|
||||
yAxisIndex: 2,
|
||||
data: smoothData(80, 180, xLabels.value, Math.PI / 3, 1, 0, 2),
|
||||
},
|
||||
{
|
||||
name: "Feed TK1",
|
||||
color: pastelColors[3],
|
||||
yAxisIndex: 3,
|
||||
data: smoothData(2, 8, xLabels.value, Math.PI / 4, 1, 0, 3),
|
||||
},
|
||||
{
|
||||
name: "Feed TK2",
|
||||
color: pastelColors[4],
|
||||
yAxisIndex: 4,
|
||||
data: smoothData(2, 8, xLabels.value, Math.PI / 5, 1, 0, 4),
|
||||
},
|
||||
{
|
||||
name: "pH",
|
||||
color: pastelColors[5],
|
||||
yAxisIndex: 5,
|
||||
data: smoothData(6.7, 7.6, xLabels.value, Math.PI / 6, 1, 0, 5),
|
||||
},
|
||||
{
|
||||
name: "Pressure",
|
||||
color: pastelColors[6],
|
||||
yAxisIndex: 6,
|
||||
data: smoothData(0.5, 1.5, xLabels.value, Math.PI / 7, 1, 0, 6),
|
||||
},
|
||||
{
|
||||
name: "RPM",
|
||||
color: pastelColors[7],
|
||||
yAxisIndex: 7,
|
||||
data: smoothData(1000, 2500, xLabels.value, Math.PI / 8, 1, 0, 7),
|
||||
},
|
||||
{
|
||||
name: "CO2",
|
||||
color: pastelColors[8],
|
||||
yAxisIndex: 8,
|
||||
data: smoothData(2, 8, xLabels.value, Math.PI / 9, 1, 0, 8),
|
||||
},
|
||||
{
|
||||
name: "JAR Vol",
|
||||
color: pastelColors[9],
|
||||
yAxisIndex: 9,
|
||||
data: smoothData(5, 18, xLabels.value, Math.PI / 10, 1, 0, 9),
|
||||
},
|
||||
{
|
||||
name: "WEIGHT",
|
||||
color: pastelColors[10],
|
||||
yAxisIndex: 10,
|
||||
data: smoothData(20, 90, xLabels.value, Math.PI / 11, 1, 0, 10),
|
||||
},
|
||||
{
|
||||
name: "O2",
|
||||
color: pastelColors[11],
|
||||
yAxisIndex: 11,
|
||||
data: smoothData(20, 90, xLabels.value, Math.PI / 12, 1, 0, 11),
|
||||
},
|
||||
{
|
||||
name: "NV",
|
||||
color: pastelColors[12],
|
||||
yAxisIndex: 12,
|
||||
data: smoothData(2, 8, xLabels.value, Math.PI / 13, 1, 0, 12),
|
||||
},
|
||||
{
|
||||
name: "NIR",
|
||||
color: pastelColors[13],
|
||||
yAxisIndex: 13,
|
||||
data: smoothData(2, 8, xLabels.value, Math.PI / 14, 1, 0, 13),
|
||||
},
|
||||
{
|
||||
name: "Temperature",
|
||||
color: pastelColors[14],
|
||||
yAxisIndex: 14,
|
||||
data: smoothData(30, 38, xLabels.value, Math.PI / 15, 1, 0, 14),
|
||||
},
|
||||
]);
|
||||
|
||||
const batchMarkLines = [
|
||||
// 배치1
|
||||
[
|
||||
{ xAxis: 6 * 3600, name: "1F" },
|
||||
{ xAxis: 10 * 3600, name: "2F" },
|
||||
{ xAxis: 12 * 3600, name: "Seed" },
|
||||
{ xAxis: 22 * 3600, name: "Main" },
|
||||
],
|
||||
// 배치2
|
||||
[
|
||||
{ xAxis: 12 * 3600, name: "1F" },
|
||||
{ xAxis: 18 * 3600, name: "2F" },
|
||||
{ xAxis: 16 * 3600, name: "Seed" },
|
||||
{ xAxis: 36 * 3600, name: "Main" },
|
||||
],
|
||||
// 배치3
|
||||
[
|
||||
{ xAxis: 15 * 3600, name: "1F" },
|
||||
{ xAxis: 30 * 3600, name: "2F" },
|
||||
{ xAxis: 45 * 3600, name: "Seed" },
|
||||
{ xAxis: 60 * 3600, name: "Main" },
|
||||
],
|
||||
// 배치4
|
||||
[
|
||||
{ xAxis: 8 * 3600, name: "1F" },
|
||||
{ xAxis: 16 * 3600, name: "2F" },
|
||||
{ xAxis: 24 * 3600, name: "Seed" },
|
||||
{ xAxis: 50 * 3600, name: "Main" },
|
||||
],
|
||||
];
|
||||
|
||||
// --- 차트 렌더링 로직 (간단화) ---
|
||||
const growthChartRef = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
const renderChart = () => {
|
||||
if (!growthChartRef.value) return;
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(growthChartRef.value);
|
||||
}
|
||||
const yAxis = filteredYAxisList.value.map((y, i) => {
|
||||
const visibleIdx = visibleYAxisList.value.findIndex(
|
||||
vy => vy.name === y.name
|
||||
);
|
||||
return {
|
||||
type: "value",
|
||||
min: y.min,
|
||||
max: y.max,
|
||||
show: visibleIdx !== -1,
|
||||
position: "left",
|
||||
offset: visibleIdx === 1 ? 80 : 0,
|
||||
z: 10 + i,
|
||||
name: visibleIdx !== -1 ? y.name + (y.unit ? ` ${y.unit}` : "") : "",
|
||||
nameLocation: "middle",
|
||||
nameGap: 50,
|
||||
nameTextStyle: {
|
||||
color: y.color,
|
||||
fontWeight: "bold",
|
||||
fontSize: 11,
|
||||
writing: "tb-rl",
|
||||
},
|
||||
axisLabel: {
|
||||
show: visibleIdx !== -1,
|
||||
fontSize: 12,
|
||||
color: y.color,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
splitLine: {
|
||||
show: visibleIdx === 1 || visibleIdx === 0,
|
||||
lineStyle: { color: "#f0f0f0", width: 1 },
|
||||
},
|
||||
axisLine: {
|
||||
show: visibleIdx !== -1,
|
||||
lineStyle: { color: y.color, width: 2 },
|
||||
},
|
||||
axisTick: {
|
||||
show: visibleIdx !== -1,
|
||||
lineStyle: { color: y.color },
|
||||
},
|
||||
};
|
||||
});
|
||||
const markLine = {
|
||||
symbol: "none",
|
||||
label: {
|
||||
show: true,
|
||||
position: "insideEndTop",
|
||||
fontWeight: "bold",
|
||||
fontSize: 13,
|
||||
color: "#222",
|
||||
formatter: function (params: { data?: { name?: string } }) {
|
||||
return params.data && params.data.name ? params.data.name : "";
|
||||
},
|
||||
},
|
||||
lineStyle: { color: "#888", width: 2, type: "solid" },
|
||||
data: batchMarkLines[props.batchIndex] || [],
|
||||
};
|
||||
const option = {
|
||||
grid: { left: 120, right: 60, top: 40, bottom: 120, containLabel: true },
|
||||
xAxis: [
|
||||
{
|
||||
type: "value",
|
||||
splitNumber: 20,
|
||||
position: "bottom",
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
fontWeight: "normal",
|
||||
interval: 0,
|
||||
hideOverlap: true,
|
||||
formatter: function (value: number) {
|
||||
const totalSeconds = Math.round(value);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
},
|
||||
},
|
||||
splitLine: { show: true, lineStyle: { color: "#e0e0e0", width: 1 } },
|
||||
axisLine: { show: true, lineStyle: { color: "#ccc", width: 1 } },
|
||||
axisTick: { show: true, lineStyle: { color: "#ccc" } },
|
||||
},
|
||||
],
|
||||
yAxis,
|
||||
series: filteredSeriesList.value.map((s, idx) => ({
|
||||
...s,
|
||||
yAxisIndex: filteredYAxisList.value.findIndex(y => y.name === s.name),
|
||||
type: "line",
|
||||
symbol: "none",
|
||||
sampling: "lttb",
|
||||
lineStyle: { width: 0.5 },
|
||||
...(idx === 0 ? { markLine } : {}),
|
||||
})),
|
||||
legend: { show: false },
|
||||
animation: false,
|
||||
};
|
||||
chartInstance.setOption(option, true);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
|
||||
watch([checkedList, yAxisScrollIndex, () => props.batchIndex], () => {
|
||||
renderChart();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// 줌 컨트롤 함수
|
||||
const currentZoomLevel = ref(1);
|
||||
const zoomStep = 0.2;
|
||||
const zoomIn = () => {
|
||||
if (chartInstance) {
|
||||
currentZoomLevel.value = Math.min(currentZoomLevel.value + zoomStep, 3);
|
||||
applyZoom();
|
||||
}
|
||||
};
|
||||
const zoomOut = () => {
|
||||
if (chartInstance) {
|
||||
currentZoomLevel.value = Math.max(currentZoomLevel.value - zoomStep, 0.5);
|
||||
applyZoom();
|
||||
}
|
||||
};
|
||||
const resetZoom = () => {
|
||||
if (chartInstance) {
|
||||
currentZoomLevel.value = 1;
|
||||
applyZoom();
|
||||
}
|
||||
};
|
||||
const applyZoom = () => {
|
||||
if (!chartInstance) return;
|
||||
const option = chartInstance.getOption();
|
||||
const xDataZoom = {
|
||||
type: "inside",
|
||||
xAxisIndex: 0,
|
||||
start: 0,
|
||||
end: 100 / currentZoomLevel.value,
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: false,
|
||||
preventDefaultMouseMove: true,
|
||||
};
|
||||
const yDataZoom = visibleYAxisList.value.map((_, index) => ({
|
||||
type: "inside",
|
||||
yAxisIndex: index,
|
||||
start: 0,
|
||||
end: 100 / currentZoomLevel.value,
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseWheel: false,
|
||||
preventDefaultMouseMove: true,
|
||||
}));
|
||||
option.dataZoom = [xDataZoom, ...yDataZoom];
|
||||
chartInstance.setOption(option, false);
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 1000px;
|
||||
}
|
||||
.main-graph-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.echarts-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 1000px;
|
||||
}
|
||||
.echarts-graph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 1000px;
|
||||
}
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
.zoom-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.zoom-btn:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #bbb;
|
||||
color: #333;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.zoom-btn.reset {
|
||||
font-size: 14px;
|
||||
}
|
||||
.zoom-btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.yaxis-scroll-bar-overlay {
|
||||
position: absolute;
|
||||
left: 60px;
|
||||
bottom: 70px;
|
||||
width: 120px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
transform: translateY(50%);
|
||||
background: none;
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: none;
|
||||
z-index: 30;
|
||||
padding: 0 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.yaxis-scroll-bar-overlay input[type="range"] {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
accent-color: #bbb;
|
||||
background: rgba(180, 180, 180, 0.1);
|
||||
border-radius: 8px;
|
||||
opacity: 0.4;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.yaxis-scroll-bar-overlay input[type="range"]::-webkit-slider-thumb {
|
||||
background: #eee;
|
||||
border: 1.5px solid #ccc;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.yaxis-scroll-bar-overlay input[type="range"]:hover {
|
||||
box-shadow: 0 0 0 2px #bbb2;
|
||||
}
|
||||
</style>
|
42
components/BatchTabs.vue
Normal file
42
components/BatchTabs.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(name, idx) in batchNames"
|
||||
:key="name"
|
||||
:class="{ active: idx === currentTab }"
|
||||
@click="currentTab = idx"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
<BatchGraph :batch-index="currentTab" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import BatchGraph from "~/components/BatchGraph.vue";
|
||||
const batchNames = ["배치 1", "배치 2", "배치 3", "배치 4"];
|
||||
const currentTab = ref(0);
|
||||
</script>
|
||||
<style scoped>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tabs button.active {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
37
components/CustomContextMenu.vue
Normal file
37
components/CustomContextMenu.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
:style="{ position: 'fixed', top: y + 'px', left: x + 'px', zIndex: 9999 }"
|
||||
class="custom-context-menu"
|
||||
@click.stop
|
||||
>
|
||||
<div class="menu-item">• Output Real-time Data</div>
|
||||
<div class="menu-item">• Input Sampling Data</div>
|
||||
<div class="menu-item">• 설비가동시간</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ visible: boolean; x: number; y: number }>();
|
||||
</script>
|
||||
<style scoped>
|
||||
.custom-context-menu {
|
||||
background: #fff;
|
||||
border: 2px solid #bbb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
padding: 8px 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
margin: 4px 0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
</style>
|
66
components/PageDescription.vue
Normal file
66
components/PageDescription.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="page-description">
|
||||
<button class="toggle-btn" @click="toggleOpen">
|
||||
<span v-if="isOpen">▼</span>
|
||||
<span v-else>▲</span>
|
||||
<span>{{ isOpen ? "설명 접기" : "설명 펼치기" }}</span>
|
||||
</button>
|
||||
<div v-show="isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const isOpen = ref(false);
|
||||
function toggleOpen() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-description {
|
||||
background: #fff; /* 기존 #f5f7fa → #fff */
|
||||
border-left: 5px solid #3498db; /* 기존 4px #409eff → 5px #3498db */
|
||||
border: 1px solid #ddd; /* 추가: 박스 테두리 */
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
:deep(h1) {
|
||||
color: #2c3e50;
|
||||
}
|
||||
:deep(h2) {
|
||||
margin-top: 30px;
|
||||
color: #34495e;
|
||||
}
|
||||
:deep(ul) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
:deep(.highlight) {
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.toggle-btn {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.1em;
|
||||
color: #3498db;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.toggle-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
28
composables/useApi.ts
Normal file
28
composables/useApi.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useFetch, useRuntimeConfig, useCookie } from '#imports'
|
||||
|
||||
export const useApi = <T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: 'get' | 'post' | 'put' | 'delete'
|
||||
body?: any
|
||||
query?: Record<string, any>
|
||||
headers?: HeadersInit
|
||||
server?: boolean // ← 이 줄 추가!
|
||||
} = {}
|
||||
) => {
|
||||
const config = useRuntimeConfig()
|
||||
const token = useCookie('token')
|
||||
|
||||
const method = options.method ? options.method.toUpperCase() : 'GET'
|
||||
|
||||
return useFetch<T>(() => `${config.public.apiBase}${path}`, {
|
||||
method: method as any, // 타입 강제 우회
|
||||
body: options.body,
|
||||
query: options.query,
|
||||
headers: {
|
||||
Authorization: token.value ? `Bearer ${token.value}` : '',
|
||||
...options.headers
|
||||
},
|
||||
server: options.server // ← 이 줄 추가!
|
||||
})
|
||||
}
|
25
composables/useCounter.ts
Normal file
25
composables/useCounter.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const useCounter = () => {
|
||||
const count = ref(0);
|
||||
|
||||
const increment = () => {
|
||||
count.value++;
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
count.value--;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
count.value = 0;
|
||||
};
|
||||
|
||||
const double = computed(() => count.value * 2);
|
||||
|
||||
return {
|
||||
count: readonly(count),
|
||||
increment,
|
||||
decrement,
|
||||
reset,
|
||||
double,
|
||||
};
|
||||
};
|
8
composables/useOverlay.ts
Normal file
8
composables/useOverlay.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default async function useOverlay() {
|
||||
if (import.meta.server) {
|
||||
// SSR에서는 cytoscape-overlays를 사용하지 않음
|
||||
return null
|
||||
}
|
||||
// 전체 export 객체를 반환
|
||||
return await import('cytoscape-overlays')
|
||||
}
|
9
composables/useSidebar.ts
Normal file
9
composables/useSidebar.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useSidebar = () => {
|
||||
const isSidebarOpen = ref(true);
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
};
|
||||
return { isSidebarOpen, toggleSidebar };
|
||||
};
|
13
eslint.config.mjs
Normal file
13
eslint.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
{
|
||||
rules: {
|
||||
"vue/html-self-closing": "off",
|
||||
"vue/html-closing-bracket-newline": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
}
|
||||
);
|
29
layouts/auth.vue
Normal file
29
layouts/auth.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<main class="auth-main">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 인증 페이지용 레이아웃 - 헤더와 푸터 없음
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
165
layouts/default.vue
Normal file
165
layouts/default.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<AppHeader v-model="activeMenu" @update:model-value="onMenuClick" />
|
||||
<nav
|
||||
v-if="subMenus && subMenus.length && showSubmenuBar"
|
||||
class="submenu-bar"
|
||||
@click.stop
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="sub in subMenus"
|
||||
:key="sub.key"
|
||||
:to="sub.to"
|
||||
class="submenu-btn"
|
||||
:class="{ active: $route.path === sub.to }"
|
||||
>
|
||||
{{ sub.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<main class="main">
|
||||
<slot />
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<p>© 2024 Nuxt.js App</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const activeMenu = ref("home");
|
||||
const showSubmenuBar = ref(false);
|
||||
|
||||
// HOME 메뉴가 선택되었을 때 최상단 경로로 이동
|
||||
watch(activeMenu, newValue => {
|
||||
if (newValue === "home") {
|
||||
router.push("/");
|
||||
}
|
||||
});
|
||||
|
||||
watch(route, () => {
|
||||
showSubmenuBar.value = false;
|
||||
});
|
||||
|
||||
const subMenus = computed(() => {
|
||||
if (activeMenu.value === "test") {
|
||||
return [
|
||||
{ key: "test", label: "테스트", to: "/test/test01" },
|
||||
{ key: "igv", label: "ivg", to: "/test/test02" },
|
||||
{ key: "igv2", label: "ivg2", to: "/test/igv2" },
|
||||
{ key: "pathway", label: "pathway", to: "/test/pathway" },
|
||||
{ key: "pathway2", label: "pathway2", to: "/test/pathway2" },
|
||||
{ key: "pathway3", label: "pathway3", to: "/test/pathway3" },
|
||||
{ key: "pathway4", label: "pathway4", to: "/cultureGraph/pathway4" },
|
||||
{ key: "pathwayjson", label: "pathwayjson", to: "/test/pathwayjson" },
|
||||
{ key: "cultureGraph", label: "배양그래프", to: "/test/culture-graph" },
|
||||
{
|
||||
key: "cultureGraphMulti",
|
||||
label: "배양그래프 멀티",
|
||||
to: "/test/culture-graph-multi",
|
||||
},
|
||||
{
|
||||
key: "cultureGraphTab",
|
||||
label: "배양그래프 탭",
|
||||
to: "/test/culture-graph-tab",
|
||||
},
|
||||
];
|
||||
} else if (activeMenu.value === "admin") {
|
||||
return [
|
||||
{ key: "logs", label: "접속기록", to: "/admin/logs" },
|
||||
{ key: "codes", label: "공통코드", to: "/admin/codes" },
|
||||
{ key: "programs", label: "프로그램", to: "/admin/programs" },
|
||||
];
|
||||
}
|
||||
// HOME 메뉴일 때는 서브메뉴 없음
|
||||
return [];
|
||||
});
|
||||
|
||||
function onMenuClick(menu: string) {
|
||||
activeMenu.value = menu;
|
||||
showSubmenuBar.value = true;
|
||||
}
|
||||
|
||||
function handleClickOutsideSubmenuBar(event: MouseEvent) {
|
||||
const submenu = document.querySelector(".submenu-bar");
|
||||
// menu-btn(대메뉴) 클릭 시에는 닫히지 않도록 예외 처리
|
||||
if (
|
||||
submenu &&
|
||||
!submenu.contains(event.target as Node) &&
|
||||
!(event.target as HTMLElement).classList.contains("menu-btn")
|
||||
) {
|
||||
showSubmenuBar.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("click", handleClickOutsideSubmenuBar);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("click", handleClickOutsideSubmenuBar);
|
||||
});
|
||||
/*
|
||||
useHead({
|
||||
title: "Integrated Bio Foundry Platform",
|
||||
meta: [{ name: "description", content: "Integrated Bio Foundry Platform" }],
|
||||
});
|
||||
*/
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.footer {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.submenu-bar {
|
||||
background: #f4f6fa;
|
||||
border-bottom: 1px solid #e0e7ef;
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.submenu-btn {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submenu-btn.active {
|
||||
background: none;
|
||||
color: #1976d2;
|
||||
}
|
||||
.submenu-btn:hover {
|
||||
background: #e6f0fa;
|
||||
color: #1976d2;
|
||||
}
|
||||
</style>
|
27
middleware/auth.ts
Normal file
27
middleware/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export default defineNuxtRouteMiddleware((to, _from) => {
|
||||
// 클라이언트 사이드에서만 실행
|
||||
if (import.meta.client) {
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 보호된 라우트 목록(메뉴 확정되면 수정)
|
||||
const protectedRoutes = ["/admin", "/profile", "/dashboard"];
|
||||
|
||||
// 현재 라우트가 보호된 라우트인지 확인
|
||||
const isProtectedRoute = protectedRoutes.some((route) =>
|
||||
to.path.startsWith(route)
|
||||
);
|
||||
|
||||
// 관리자 전용 라우트 확인
|
||||
const isAdminRoute = to.path.startsWith("/admin");
|
||||
|
||||
if (isProtectedRoute && !userStore.isLoggedIn) {
|
||||
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
|
||||
return navigateTo("/login");
|
||||
}
|
||||
|
||||
if (isAdminRoute && !userStore.isAdmin) {
|
||||
// 관리자가 아닌 사용자를 홈 페이지로 리다이렉트
|
||||
return navigateTo("/");
|
||||
}
|
||||
}
|
||||
});
|
43
nuxt.config.ts
Normal file
43
nuxt.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-05-15",
|
||||
devtools: { enabled: true },
|
||||
modules: [
|
||||
"@nuxt/eslint",
|
||||
"@nuxt/image",
|
||||
"@nuxt/icon",
|
||||
"@pinia/nuxt",
|
||||
"@nuxtjs/tailwindcss",
|
||||
],
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/icon?family=Material+Icons",
|
||||
},
|
||||
],
|
||||
script: [
|
||||
{ src: '/dist/igv.js', defer: true }
|
||||
]
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
include: ['cytoscape-overlays'],
|
||||
},
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
logLevel: 'debug'
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: 'http://localhost'
|
||||
}
|
||||
}
|
||||
});
|
14506
package-lock.json
generated
Normal file
14506
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/eslint": "^1.4.1",
|
||||
"@nuxt/icon": "^1.14.0",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/tailwindcss": "^7.0.0-beta.0",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"ag-grid-community": "^34.0.0",
|
||||
"ag-grid-vue3": "^34.0.0",
|
||||
"echarts": "^5.6.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"cytoscape": "^3.32.0",
|
||||
"cytoscape-layers": "^3.0.0",
|
||||
"cytoscape-overlays": "^2.0.0",
|
||||
"eslint": "^9.29.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.21",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.11"
|
||||
}
|
||||
}
|
276
pages/about.vue
Normal file
276
pages/about.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<section class="hero">
|
||||
<h1>About Us</h1>
|
||||
<p class="subtitle">혁신적인 솔루션으로 미래를 만들어갑니다</p>
|
||||
</section>
|
||||
|
||||
<section class="mission">
|
||||
<h2>Our Mission</h2>
|
||||
<p>
|
||||
우리는 최신 기술을 활용하여 사용자 중심의 혁신적인 제품을 개발하고, 더
|
||||
나은 디지털 경험을 제공하는 것을 목표로 합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="values">
|
||||
<h2>Our Values</h2>
|
||||
<div class="values-grid">
|
||||
<div class="value-card">
|
||||
<h3>혁신</h3>
|
||||
<p>끊임없는 혁신을 통해 새로운 가치를 창출합니다</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<h3>품질</h3>
|
||||
<p>최고의 품질을 위해 세심한 주의를 기울입니다</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<h3>협력</h3>
|
||||
<p>팀워크와 협력을 통해 더 큰 성과를 달성합니다</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<h3>성장</h3>
|
||||
<p>지속적인 학습과 성장을 추구합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="team">
|
||||
<h2>Our Team</h2>
|
||||
<div class="team-grid">
|
||||
<div class="team-member">
|
||||
<div class="member-avatar">
|
||||
<span>👨💻</span>
|
||||
</div>
|
||||
<h3>김개발</h3>
|
||||
<p class="position">Frontend Developer</p>
|
||||
<p>
|
||||
Vue.js와 Nuxt.js 전문가로 사용자 경험에 중점을 둔 개발을 담당합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="team-member">
|
||||
<div class="member-avatar">
|
||||
<span>👩💻</span>
|
||||
</div>
|
||||
<h3>이디자인</h3>
|
||||
<p class="position">UI/UX Designer</p>
|
||||
<p>사용자 중심의 직관적이고 아름다운 인터페이스를 설계합니다.</p>
|
||||
</div>
|
||||
<div class="team-member">
|
||||
<div class="member-avatar">
|
||||
<span>👨🔧</span>
|
||||
</div>
|
||||
<h3>박백엔드</h3>
|
||||
<p class="position">Backend Developer</p>
|
||||
<p>안정적이고 확장 가능한 서버 아키텍처를 구축합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="contact">
|
||||
<h2>Contact Us</h2>
|
||||
<p>궁금한 점이 있으시면 언제든 연락주세요!</p>
|
||||
<div class="contact-info">
|
||||
<p>📧 Email: contact@example.com</p>
|
||||
<p>📱 Phone: 02-1234-5678</p>
|
||||
<p>📍 Address: 서울특별시 강남구 테헤란로 123</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 페이지 메타데이터 설정
|
||||
definePageMeta({
|
||||
title: "About",
|
||||
description: "우리 팀과 미션에 대해 알아보세요",
|
||||
});
|
||||
|
||||
// SEO 최적화
|
||||
useHead({
|
||||
title: "About Us - Nuxt.js App",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
|
||||
},
|
||||
{ property: "og:title", content: "About Us - Nuxt.js App" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mission {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mission p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.values-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.value-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.value-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.value-card h3 {
|
||||
color: #00dc82;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.value-card p {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.team-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.team-member {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.team-member:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #00dc82, #00b894);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.team-member h3 {
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.position {
|
||||
color: #00dc82;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.team-member p:last-child {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contact {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.contact p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.contact-info p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.values-grid,
|
||||
.team-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.mission,
|
||||
.contact {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
173
pages/admin/codes.vue
Normal file
173
pages/admin/codes.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="codes-page">
|
||||
<div class="page-header">
|
||||
<h1>공통코드</h1>
|
||||
<div class="page-actions">
|
||||
<button class="action-btn">코드 추가</button>
|
||||
<button class="action-btn">그룹코드 추가</button>
|
||||
<button class="action-btn primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aggrid-section">
|
||||
<div class="aggrid-title">그룹코드</div>
|
||||
<div class="aggrid-container">
|
||||
<ag-grid-vue
|
||||
class="ag-theme-material"
|
||||
style="width: 100%; height: 180px"
|
||||
:column-defs="groupColDefs"
|
||||
:row-data="groupRowData"
|
||||
:default-col-def="defaultColDef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aggrid-section">
|
||||
<div class="aggrid-title">코드</div>
|
||||
<div class="aggrid-container">
|
||||
<ag-grid-vue
|
||||
class="ag-theme-material"
|
||||
style="width: 100%; height: 320px"
|
||||
:column-defs="codeColDefs"
|
||||
:row-data="codeRowData"
|
||||
:default-col-def="defaultColDef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { AgGridVue } from "ag-grid-vue3";
|
||||
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||
import type { GroupCode, Code, ColDef, DefaultColDef } from "~/types/ag-grid";
|
||||
|
||||
// AG Grid 모듈 등록
|
||||
onMounted(() => {
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
});
|
||||
|
||||
const groupColDefs = ref<ColDef[]>([
|
||||
{ headerName: "그룹 코드", field: "groupCode", width: 140 },
|
||||
{ headerName: "그룹 코드명", field: "groupName", width: 180 },
|
||||
{ headerName: "사용 여부", field: "useYn", width: 100 },
|
||||
{ headerName: "정렬 순서", field: "order", width: 100 },
|
||||
]);
|
||||
|
||||
const groupRowData = ref<GroupCode[]>([
|
||||
{
|
||||
groupCode: "MONITORING_001",
|
||||
groupName: "모니터링 기본정보",
|
||||
useYn: "Y",
|
||||
order: 1,
|
||||
},
|
||||
{ groupCode: "RESOURCE_001", groupName: "리소스 유형", useYn: "Y", order: 6 },
|
||||
]);
|
||||
|
||||
const codeColDefs = ref<ColDef[]>([
|
||||
{ headerName: "코드", field: "code", width: 160 },
|
||||
{ headerName: "코드명", field: "codeName", width: 120 },
|
||||
{ headerName: "코드 상세", field: "codeDetail", width: 200 },
|
||||
{ headerName: "부모 코드", field: "parentCode", width: 120 },
|
||||
{ headerName: "사용 여부", field: "useYn", width: 100 },
|
||||
{ headerName: "정렬 순서", field: "order", width: 100 },
|
||||
]);
|
||||
|
||||
const codeRowData = ref<Code[]>([
|
||||
{
|
||||
code: "RESOURCE_001_001",
|
||||
codeName: "facility",
|
||||
codeDetail: "주요 시설을 나타냄",
|
||||
parentCode: "",
|
||||
useYn: "Y",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
code: "RESOURCE_001_002",
|
||||
codeName: "equipment",
|
||||
codeDetail: "사업에 설치된 장비",
|
||||
parentCode: "",
|
||||
useYn: "Y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
code: "RESOURCE_001_003",
|
||||
codeName: "device",
|
||||
codeDetail: "IoT 디바이스 또는 센서",
|
||||
parentCode: "",
|
||||
useYn: "Y",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
code: "RESOURCE_001_004",
|
||||
codeName: "station",
|
||||
codeDetail: "데이터 수집 또는 처리 스테이션",
|
||||
parentCode: "",
|
||||
useYn: "Y",
|
||||
order: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultColDef = ref<DefaultColDef>({
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
minWidth: 80,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codes-page {
|
||||
padding: 32px 32px 0 32px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
}
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-btn {
|
||||
background: #f4f6fa;
|
||||
border: 1px solid #e0e7ef;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 7px 18px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: #e6f0fa;
|
||||
}
|
||||
.aggrid-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.aggrid-title {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.aggrid-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
padding: 18px 18px 0 18px;
|
||||
}
|
||||
</style>
|
118
pages/admin/logs.vue
Normal file
118
pages/admin/logs.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<div class="page-header">
|
||||
<div class="breadcrumb">관리자 메뉴 / 접속기록</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<label for="date-input">날짜 설정</label>
|
||||
<input id="date-input" v-model="selectedDate" type="date" />
|
||||
</div>
|
||||
<div class="aggrid-section">
|
||||
<div class="aggrid-container">
|
||||
<ag-grid-vue
|
||||
class="ag-theme-material"
|
||||
style="width: 100%; height: 220px"
|
||||
:column-defs="colDefs"
|
||||
:row-data="filteredRows"
|
||||
:default-col-def="defaultColDef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
© 2024. Ocean Monitoring System. All Rights Reserved.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { AgGridVue } from "ag-grid-vue3";
|
||||
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||
import type { LogEntry, ColDef, DefaultColDef } from "~/types/ag-grid";
|
||||
|
||||
onMounted(() => {
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
});
|
||||
|
||||
const selectedDate = ref<string>("2025-06-27");
|
||||
const rows = ref<LogEntry[]>([
|
||||
{ account: "admin3", datetime: "Jun 27, 2025, 9:51:04 AM", ip: "172.17.0.1" },
|
||||
{ account: "admin9", datetime: "Jun 27, 2025, 8:51:41 AM", ip: "172.17.0.1" },
|
||||
]);
|
||||
|
||||
const colDefs = ref<ColDef[]>([
|
||||
{ headerName: "계정", field: "account", width: 160 },
|
||||
{ headerName: "접속 일시", field: "datetime", width: 200 },
|
||||
{ headerName: "IP", field: "ip", width: 160 },
|
||||
]);
|
||||
|
||||
const defaultColDef = ref<DefaultColDef>({
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
minWidth: 80,
|
||||
});
|
||||
|
||||
const filteredRows = computed<LogEntry[]>(() => {
|
||||
// 실제 구현시 날짜 필터링 적용
|
||||
return rows.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
padding: 32px 32px 0 32px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 1.08rem;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
.filter-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.filter-section label {
|
||||
font-size: 1rem;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
.filter-section input[type="date"] {
|
||||
border: 1px solid #e0e7ef;
|
||||
border-radius: 5px;
|
||||
padding: 6px 12px;
|
||||
font-size: 1rem;
|
||||
color: #222;
|
||||
background: #f4f6fa;
|
||||
}
|
||||
.aggrid-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.aggrid-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
padding: 18px 18px 0 18px;
|
||||
}
|
||||
.table-info {
|
||||
font-size: 0.98rem;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.copyright {
|
||||
text-align: right;
|
||||
color: #888;
|
||||
font-size: 0.98rem;
|
||||
margin: 32px 8px 0 0;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
</style>
|
148
pages/admin/programs.vue
Normal file
148
pages/admin/programs.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="codes-page">
|
||||
<div class="page-header">
|
||||
<h1>프로그램 관리</h1>
|
||||
<div class="page-actions">
|
||||
<button class="action-btn">프로그램 추가</button>
|
||||
<button class="action-btn primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aggrid-section">
|
||||
<div class="aggrid-title">프로그램 목록</div>
|
||||
<div class="aggrid-container">
|
||||
<ag-grid-vue
|
||||
class="ag-theme-material"
|
||||
style="width: 100%; height: 600px"
|
||||
:column-defs="programColDefs"
|
||||
:row-data="programRowData"
|
||||
:default-col-def="defaultColDef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { AgGridVue } from "ag-grid-vue3";
|
||||
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||
import type { Program, ColDef, DefaultColDef } from "~/types/ag-grid";
|
||||
|
||||
onMounted(() => {
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
});
|
||||
|
||||
const programColDefs = ref<ColDef[]>([
|
||||
{ headerName: "부모 코드", field: "parentCode", width: 100 },
|
||||
{ headerName: "레벨", field: "level", width: 60 },
|
||||
{ headerName: "코드", field: "code", width: 120 },
|
||||
{ headerName: "이름", field: "name", width: 160 },
|
||||
{ headerName: "사용 여부", field: "useYn", width: 80 },
|
||||
{ headerName: "메뉴 여부", field: "menuYn", width: 80 },
|
||||
{ headerName: "API 여부", field: "apiYn", width: 80 },
|
||||
{ headerName: "예외 허용 여부", field: "exceptionYn", width: 110 },
|
||||
{ headerName: "표시 순서", field: "order", width: 80 },
|
||||
{ headerName: "uri", field: "uri", width: 180 },
|
||||
{ headerName: "필드1", field: "field1", width: 100 },
|
||||
{ headerName: "필드2", field: "field2", width: 100 },
|
||||
{ headerName: "필드3", field: "field3", width: 100 },
|
||||
]);
|
||||
|
||||
const programRowData = ref<Program[]>([
|
||||
{
|
||||
parentCode: "OMS01",
|
||||
level: 1,
|
||||
code: "OMS01_01",
|
||||
name: "DASHBOARD",
|
||||
useYn: true,
|
||||
menuYn: true,
|
||||
apiYn: false,
|
||||
exceptionYn: false,
|
||||
order: 1,
|
||||
uri: "/dashboard/overview",
|
||||
field1: "ri-pie-chart-2-line",
|
||||
field2: "",
|
||||
field3: "",
|
||||
},
|
||||
{
|
||||
parentCode: "OMS01_02",
|
||||
level: 2,
|
||||
code: "OMS01_02_01",
|
||||
name: "IoT 센서이력 조회",
|
||||
useYn: true,
|
||||
menuYn: true,
|
||||
apiYn: false,
|
||||
exceptionYn: false,
|
||||
order: 5,
|
||||
uri: "/environmental/iot-sensor-history",
|
||||
field1: "ri-line-chart-line",
|
||||
field2: "",
|
||||
field3: "",
|
||||
},
|
||||
// ... (샘플 데이터 추가)
|
||||
]);
|
||||
|
||||
const defaultColDef = ref<DefaultColDef>({
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
minWidth: 80,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codes-page {
|
||||
padding: 32px 32px 0 32px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin: 0;
|
||||
}
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-btn {
|
||||
background: #f4f6fa;
|
||||
border: 1px solid #e0e7ef;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 7px 18px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: #e6f0fa;
|
||||
}
|
||||
.aggrid-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.aggrid-title {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.aggrid-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
padding: 18px 18px 0 18px;
|
||||
}
|
||||
</style>
|
123
pages/index.vue
Normal file
123
pages/index.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
Integrated Bio Foundry Platform
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600">
|
||||
통합 바이오 파운드리 플랫폼에 오신 것을 환영합니다
|
||||
</p>
|
||||
|
||||
<!-- 사용자 환영 메시지 -->
|
||||
<div
|
||||
v-if="userStore.isLoggedIn"
|
||||
class="mt-6 p-4 bg-white rounded-lg shadow-md inline-block"
|
||||
>
|
||||
<p class="text-lg text-gray-800 mb-2">
|
||||
안녕하세요,
|
||||
<span class="font-semibold text-blue-600">{{
|
||||
userStore.userName
|
||||
}}</span
|
||||
>님!
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ userStore.isAdmin ? "관리자" : "사용자" }} 권한으로
|
||||
로그인되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-6 p-4 bg-yellow-50 rounded-lg shadow-md inline-block"
|
||||
>
|
||||
<p class="text-lg text-gray-800 mb-2">로그인이 필요합니다</p>
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
class="inline-block mt-2 bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
로그인하기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tailwind CSS 테스트 섹션 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<span class="text-white font-bold">1</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 1</h3>
|
||||
<p class="text-gray-600">Tailwind CSS가 정상 작동하고 있습니다!</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<span class="text-white font-bold">2</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 2</h3>
|
||||
<p class="text-gray-600">반응형 디자인이 적용되었습니다.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
||||
>
|
||||
<span class="text-white font-bold">3</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 3</h3>
|
||||
<p class="text-gray-600">모던한 UI 컴포넌트를 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 테스트 -->
|
||||
<div class="text-center space-x-4">
|
||||
<button
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Primary Button
|
||||
</button>
|
||||
<button
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Secondary Button
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
// 페이지 메타데이터 설정
|
||||
definePageMeta({
|
||||
title: "Home",
|
||||
description: "Welcome to our Nuxt.js application",
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00dc82;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
223
pages/login.vue
Normal file
223
pages/login.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="login-bg">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">Integrated Bio Foundry Platform</h1>
|
||||
<div class="login-form">
|
||||
<h2 class="login-signin">Sign In</h2>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div v-if="errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<label class="login-label" for="userId">ID</label>
|
||||
<input
|
||||
id="userId"
|
||||
v-model="userId"
|
||||
class="login-input"
|
||||
type="text"
|
||||
placeholder="아이디를 입력하세요"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<label class="login-label" for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="login-input"
|
||||
type="password"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
:disabled="isLoading"
|
||||
@keyup.enter="signIn"
|
||||
/>
|
||||
<button class="login-btn" :disabled="isLoading" @click="signIn">
|
||||
<span v-if="isLoading">로그인 중...</span>
|
||||
<span v-else>SIGN IN</span>
|
||||
</button>
|
||||
|
||||
<!-- 테스트 계정 안내 -->
|
||||
<div class="test-accounts">
|
||||
<p class="test-title">테스트 계정:</p>
|
||||
<p class="test-account">관리자: admin / stam1201!</p>
|
||||
<p class="test-account">일반 사용자: user / stam1201!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
// auth 레이아웃 사용
|
||||
definePageMeta({
|
||||
layout: "auth",
|
||||
});
|
||||
|
||||
const userId = ref("");
|
||||
const password = ref("");
|
||||
const errorMessage = ref("");
|
||||
const isLoading = ref(false);
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 이미 로그인된 경우 홈으로 리다이렉션
|
||||
onMounted(() => {
|
||||
if (userStore.isLoggedIn) {
|
||||
router.push("/");
|
||||
}
|
||||
});
|
||||
|
||||
async function signIn() {
|
||||
if (!userId.value || !password.value) {
|
||||
errorMessage.value = "아이디와 비밀번호를 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const result = await userStore.login(userId.value, password.value);
|
||||
|
||||
if (result.success) {
|
||||
// 로그인 성공 시 홈으로 이동
|
||||
await router.push("/");
|
||||
} else {
|
||||
errorMessage.value = result.error || "로그인에 실패했습니다.";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = "로그인 중 오류가 발생했습니다.";
|
||||
console.error("로그인 오류:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-bg {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fb;
|
||||
padding: 0;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px 0 rgba(44, 62, 80, 0.08);
|
||||
padding: 40px 36px 32px 36px;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: #23272f;
|
||||
font-family: "Montserrat", "Pretendard", sans-serif;
|
||||
}
|
||||
.login-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.login-signin {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #23272f;
|
||||
}
|
||||
.login-label {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 4px;
|
||||
color: #6b7280;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #f1f5fb;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.login-input:focus {
|
||||
border: 1.5px solid #4666e5;
|
||||
}
|
||||
.login-input:disabled {
|
||||
background: #f3f4f6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 10px 0;
|
||||
background: #4666e5;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: #3451b2;
|
||||
}
|
||||
.login-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.test-accounts {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.test-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.test-account {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin: 4px 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-card {
|
||||
padding: 24px 8px 20px 8px;
|
||||
min-width: 0;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
10
plugins/ag-grid.client.ts
Normal file
10
plugins/ag-grid.client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||
import "ag-grid-community/styles/ag-grid.css";
|
||||
import "ag-grid-community/styles/ag-theme-material.css";
|
||||
|
||||
// AG Grid 모듈 등록
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
// 플러그인이 클라이언트에서만 실행되도록 설정
|
||||
});
|
22
plugins/myPlugin.client.ts
Normal file
22
plugins/myPlugin.client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
return {
|
||||
provide: {
|
||||
// 통화 포맷팅 함수 제공
|
||||
formatCurrency: (amount: number): string => {
|
||||
return new Intl.NumberFormat("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// 알림 표시 함수
|
||||
showNotification: (
|
||||
message: string,
|
||||
type: "success" | "error" | "warning" = "success"
|
||||
) => {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
// 실제로는 토스트 알림 라이브러리 사용
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
81711
public/expanded_pathway.xml
Normal file
81711
public/expanded_pathway.xml
Normal file
File diff suppressed because it is too large
Load Diff
11011
public/expanded_pathway1.xml
Normal file
11011
public/expanded_pathway1.xml
Normal file
File diff suppressed because it is too large
Load Diff
31211
public/expanded_pathway10800.xml
Normal file
31211
public/expanded_pathway10800.xml
Normal file
File diff suppressed because it is too large
Load Diff
5961
public/expanded_pathway1800.xml
Normal file
5961
public/expanded_pathway1800.xml
Normal file
File diff suppressed because it is too large
Load Diff
61511
public/expanded_pathway21600.xml
Normal file
61511
public/expanded_pathway21600.xml
Normal file
File diff suppressed because it is too large
Load Diff
122111
public/expanded_pathway43200.xml
Normal file
122111
public/expanded_pathway43200.xml
Normal file
File diff suppressed because it is too large
Load Diff
2931
public/expanded_pathway720.xml
Normal file
2931
public/expanded_pathway720.xml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
362086
public/group21600.json
Normal file
362086
public/group21600.json
Normal file
File diff suppressed because it is too large
Load Diff
720646
public/group43200.json
Normal file
720646
public/group43200.json
Normal file
File diff suppressed because it is too large
Load Diff
83
public/jsonpathway.py
Normal file
83
public/jsonpathway.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
|
||||
def parse_kegg_xml_with_group(xml_file):
|
||||
tree = ET.parse(xml_file)
|
||||
root = tree.getroot()
|
||||
|
||||
pathway_info = {
|
||||
"name": root.attrib.get("name"),
|
||||
"org": root.attrib.get("org"),
|
||||
"number": root.attrib.get("number"),
|
||||
"title": root.attrib.get("title"),
|
||||
"image": root.attrib.get("image"),
|
||||
"link": root.attrib.get("link"),
|
||||
"entries": [],
|
||||
"relations": [],
|
||||
"reactions": []
|
||||
}
|
||||
|
||||
for entry in root.findall("entry"):
|
||||
entry_id = int(entry.attrib.get("id"))
|
||||
entry_group = str((entry_id // 10000) * 10000) if entry_id >= 10000 else None
|
||||
graphics = entry.find("graphics")
|
||||
entry_data = {
|
||||
"id": entry_id,
|
||||
"name": entry.attrib.get("name"),
|
||||
"type": entry.attrib.get("type"),
|
||||
"link": entry.attrib.get("link"),
|
||||
"reaction": entry.attrib.get("reaction"),
|
||||
"group": entry_group,
|
||||
"graphics": {
|
||||
"name": graphics.attrib.get("name"),
|
||||
"fgcolor": graphics.attrib.get("fgcolor"),
|
||||
"bgcolor": graphics.attrib.get("bgcolor"),
|
||||
"type": graphics.attrib.get("type"),
|
||||
"x": int(graphics.attrib.get("x")),
|
||||
"y": int(graphics.attrib.get("y")),
|
||||
"width": int(graphics.attrib.get("width")),
|
||||
"height": int(graphics.attrib.get("height")),
|
||||
}
|
||||
}
|
||||
pathway_info["entries"].append(entry_data)
|
||||
|
||||
for relation in root.findall("relation"):
|
||||
rel = {
|
||||
"entry1": int(relation.attrib.get("entry1")),
|
||||
"entry2": int(relation.attrib.get("entry2")),
|
||||
"type": relation.attrib.get("type"),
|
||||
"subtypes": []
|
||||
}
|
||||
for subtype in relation.findall("subtype"):
|
||||
rel["subtypes"].append({
|
||||
"name": subtype.attrib.get("name"),
|
||||
"value": subtype.attrib.get("value")
|
||||
})
|
||||
pathway_info["relations"].append(rel)
|
||||
|
||||
for reaction in root.findall("reaction"):
|
||||
reac = {
|
||||
"id": int(reaction.attrib.get("id")),
|
||||
"name": reaction.attrib.get("name"),
|
||||
"type": reaction.attrib.get("type"),
|
||||
"substrates": [],
|
||||
"products": []
|
||||
}
|
||||
for substrate in reaction.findall("substrate"):
|
||||
reac["substrates"].append({
|
||||
"id": int(substrate.attrib.get("id")),
|
||||
"name": substrate.attrib.get("name")
|
||||
})
|
||||
for product in reaction.findall("product"):
|
||||
reac["products"].append({
|
||||
"id": int(product.attrib.get("id")),
|
||||
"name": product.attrib.get("name")
|
||||
})
|
||||
pathway_info["reactions"].append(reac)
|
||||
|
||||
return pathway_info
|
||||
|
||||
# 사용 예:
|
||||
result = parse_kegg_xml_with_group("expanded_pathway43200.xml")
|
||||
with open("group43200.json", "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
52
public/makepathway.py
Normal file
52
public/makepathway.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
import copy
|
||||
|
||||
# 원본 XML 파일 경로
|
||||
input_file = 'pon00061.xml'
|
||||
output_file = 'expanded_pathway43200.xml'
|
||||
|
||||
# 파싱
|
||||
tree = ET.parse(input_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# entry 태그만 추출
|
||||
entries = [e for e in root.findall('entry')]
|
||||
|
||||
# 원본 entry 수 확인
|
||||
print(f'원본 entry 개수: {len(entries)}')
|
||||
|
||||
# 복제 단계 수: 10000~90000 까지 (1~9단계)
|
||||
MULTIPLIERS = range(1, 120)
|
||||
|
||||
# 새로운 entry 리스트
|
||||
new_entries = []
|
||||
|
||||
for mult in MULTIPLIERS:
|
||||
id_offset = mult * 10000
|
||||
xy_offset = mult * 2000
|
||||
|
||||
for entry in entries:
|
||||
new_entry = copy.deepcopy(entry)
|
||||
|
||||
# id 업데이트
|
||||
old_id = int(entry.attrib['id'])
|
||||
new_entry.attrib['id'] = str(old_id + id_offset)
|
||||
|
||||
# graphics 내부 x, y 업데이트
|
||||
graphics = new_entry.find('graphics')
|
||||
if graphics is not None:
|
||||
old_x = int(graphics.attrib['x'])
|
||||
old_y = int(graphics.attrib['y'])
|
||||
|
||||
graphics.attrib['x'] = str(old_x + xy_offset)
|
||||
graphics.attrib['y'] = str(old_y + xy_offset)
|
||||
|
||||
new_entries.append(new_entry)
|
||||
|
||||
# 원본 root에 추가
|
||||
for e in new_entries:
|
||||
root.append(e)
|
||||
|
||||
# 출력 저장
|
||||
tree.write(output_file, encoding='utf-8', xml_declaration=True)
|
||||
print(f'새로운 XML이 {output_file}에 저장되었습니다.')
|
422230
public/pathway21600.json
Normal file
422230
public/pathway21600.json
Normal file
File diff suppressed because it is too large
Load Diff
10882
public/pon00061.json
Normal file
10882
public/pon00061.json
Normal file
File diff suppressed because it is too large
Load Diff
2583
public/pon00061.xml
Normal file
2583
public/pon00061.xml
Normal file
File diff suppressed because it is too large
Load Diff
39
public/pon00062.xml
Normal file
39
public/pon00062.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE pathway SYSTEM "https://www.kegg.jp/kegg/xml/KGML_v0.7.2_.dtd">
|
||||
<!-- Creation date: Jul 1, 2024 17:49:43 +0900 (GMT+9) -->
|
||||
<pathway name="path:pon00061" org="pon" number="00061"
|
||||
title="Fatty acid biosynthesis"
|
||||
image="https://www.kegg.jp/kegg/pathway/pon/pon00061.png"
|
||||
link="https://www.kegg.jp/kegg-bin/show_pathway?pon00061">
|
||||
<entry id="360" name="pon:100462086" type="gene" reaction="rn:R02767"
|
||||
link="https://www.kegg.jp/dbget-bin/www_bget?pon:100462086">
|
||||
<graphics name="CBR4" fgcolor="#000000" bgcolor="#BFFFBF"
|
||||
type="rectangle" x="981" y="1260" width="46" height="17"/>
|
||||
</entry>
|
||||
<entry id="361" name="pon:100461538" type="gene"
|
||||
link="https://www.kegg.jp/dbget-bin/www_bget?pon:100461538">
|
||||
<graphics name="HSD17B8" fgcolor="#000000" bgcolor="#BFFFBF"
|
||||
type="rectangle" x="981" y="1277" width="46" height="17"/>
|
||||
</entry>
|
||||
<entry id="367" name="cpd:C05744 cpd:C00685" type="compound"
|
||||
link="https://www.kegg.jp/dbget-bin/www_bget?C05744+C00685">
|
||||
<graphics name="C05744..." fgcolor="#000000" bgcolor="#FFFFFF"
|
||||
type="circle" x="956" y="1231" width="8" height="8"/>
|
||||
</entry>
|
||||
<entry id="426" name="pon:100438452" type="gene" reaction="rn:R04355"
|
||||
link="https://www.kegg.jp/dbget-bin/www_bget?pon:100438452">
|
||||
<graphics name="OXSM" fgcolor="#000000" bgcolor="#BFFFBF"
|
||||
type="rectangle" x="855" y="1193" width="46" height="17"/>
|
||||
</entry>
|
||||
<relation entry1="426" entry2="360" type="ECrel">
|
||||
<subtype name="compound" value="367"/>
|
||||
</relation>
|
||||
<relation entry1="426" entry2="361" type="ECrel">
|
||||
<subtype name="compound" value="367"/>
|
||||
</relation>
|
||||
<reaction id="426" name="rn:R04355" type="irreversible">
|
||||
<substrate id="425" name="cpd:C03939"/>
|
||||
<substrate id="366" name="cpd:C01209"/>
|
||||
<product id="367" name="cpd:C05744"/>
|
||||
</reaction>
|
||||
</pathway>
|
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
39
server/api/fasta-files.get.bak
Normal file
39
server/api/fasta-files.get.bak
Normal file
@@ -0,0 +1,39 @@
|
||||
export default defineEventHandler(async () => {
|
||||
try {
|
||||
// Spring Boot 서버 URL (환경변수로 설정 가능)
|
||||
const springBootUrl = process.env.SPRING_BOOT_URL || 'http://localhost:8080';
|
||||
const filesUrl = `${springBootUrl}/api/fasta/files`;
|
||||
|
||||
console.log(`Spring Boot 서버에서 파일 목록 조회 중: ${filesUrl}`);
|
||||
|
||||
const response = await fetch(filesUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: `Spring Boot 서버 오류: ${errorText}`
|
||||
});
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
console.log('Spring Boot 서버 파일 목록 응답:', files);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
files: files
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('파일 목록 조회 오류:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `파일 목록 조회 실패: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
56
server/api/upload-fasta.post.bak
Normal file
56
server/api/upload-fasta.post.bak
Normal file
@@ -0,0 +1,56 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const formData = await readFormData(event);
|
||||
const file = formData.get('fasta');
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'FASTA 파일이 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
// Spring Boot 서버 URL (환경변수로 설정 가능)
|
||||
const springBootUrl = process.env.SPRING_BOOT_URL || 'http://localhost:8080';
|
||||
const uploadUrl = `${springBootUrl}/api/fasta/upload`;
|
||||
|
||||
// Spring Boot 서버로 파일 전송
|
||||
const springFormData = new FormData();
|
||||
springFormData.append('file', file);
|
||||
|
||||
console.log(`Spring Boot 서버로 파일 업로드 중: ${uploadUrl}`);
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: springFormData,
|
||||
headers: {
|
||||
// Content-Type은 FormData가 자동으로 설정
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: `Spring Boot 서버 오류: ${errorText}`
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Spring Boot 서버 응답:', result);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remoteUrl: result.fileUrl,
|
||||
fileName: result.fileName,
|
||||
fileSize: result.fileSize
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('FASTA 업로드 오류:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `파일 업로드 실패: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
3
server/tsconfig.json
Normal file
3
server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
27
stores/counter.ts
Normal file
27
stores/counter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const useCounterStore = defineStore("counter", () => {
|
||||
const count = ref(0);
|
||||
const name = ref("Counter Store");
|
||||
|
||||
const doubleCount = computed(() => count.value * 2);
|
||||
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count.value--;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count.value = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
name,
|
||||
doubleCount,
|
||||
increment,
|
||||
decrement,
|
||||
reset,
|
||||
};
|
||||
});
|
123
stores/user.ts
Normal file
123
stores/user.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
// 상태
|
||||
const isLoggedIn = ref(false);
|
||||
const user = ref<{
|
||||
id?: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
} | null>(null);
|
||||
const token = ref<string | null>(null);
|
||||
|
||||
// 게터
|
||||
const isAdmin = computed(() => user.value?.role === "admin");
|
||||
const userName = computed(() => user.value?.name || "사용자");
|
||||
|
||||
// 액션
|
||||
const login = async (userId: string, password: string) => {
|
||||
try {
|
||||
// 실제 API 호출로 대체할 수 있습니다
|
||||
/*
|
||||
const { data, error: _error } = await useApi('/login', {
|
||||
method: 'post',
|
||||
body: { id: userId, pw: password, loginLogFlag : 0 }
|
||||
})
|
||||
*/
|
||||
// 임시 로그인 로직 (실제로는 API 응답을 사용)
|
||||
if (userId && password) {
|
||||
// 테스트 계정 확인
|
||||
let mockUser;
|
||||
|
||||
if (userId === "admin" && password === "stam1201!") {
|
||||
mockUser = {
|
||||
id: "1",
|
||||
userId: "admin",
|
||||
email: "admin@test.com",
|
||||
name: "관리자",
|
||||
role: "admin",
|
||||
};
|
||||
} else if (userId === "user" && password === "stam1201!") {
|
||||
mockUser = {
|
||||
id: "2",
|
||||
userId: "user",
|
||||
email: "user@test.com",
|
||||
name: "일반사용자",
|
||||
role: "user",
|
||||
};
|
||||
} else {
|
||||
throw new Error("아이디 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
/*
|
||||
if(data && data.value){
|
||||
mockUser = data.value;
|
||||
}else{
|
||||
throw new Error("아이디 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
*/
|
||||
user.value = mockUser;
|
||||
token.value = "mock-token-" + Date.now();
|
||||
isLoggedIn.value = true;
|
||||
|
||||
// 로컬 스토리지에 저장
|
||||
localStorage.setItem("user", JSON.stringify(mockUser));
|
||||
localStorage.setItem("token", token.value);
|
||||
|
||||
return { success: true, user: mockUser };
|
||||
} else {
|
||||
throw new Error("아이디와 비밀번호를 입력해주세요.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("로그인 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "로그인에 실패했습니다.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
isLoggedIn.value = false;
|
||||
|
||||
// 로컬 스토리지에서 제거
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
};
|
||||
|
||||
const checkAuth = () => {
|
||||
// 페이지 로드 시 로컬 스토리지에서 사용자 정보 복원
|
||||
const savedUser = localStorage.getItem("user");
|
||||
const savedToken = localStorage.getItem("token");
|
||||
|
||||
if (savedUser && savedToken) {
|
||||
user.value = JSON.parse(savedUser);
|
||||
token.value = savedToken;
|
||||
isLoggedIn.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 인증 상태 확인
|
||||
if (import.meta.client) {
|
||||
checkAuth();
|
||||
}
|
||||
|
||||
return {
|
||||
// 상태
|
||||
isLoggedIn,
|
||||
user,
|
||||
token,
|
||||
|
||||
// 게터
|
||||
isAdmin,
|
||||
userName,
|
||||
|
||||
// 액션
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
};
|
||||
});
|
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
55
types/ag-grid.ts
Normal file
55
types/ag-grid.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// AG Grid 관련 공통 타입 정의
|
||||
|
||||
export interface ColDef {
|
||||
headerName: string;
|
||||
field: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface DefaultColDef {
|
||||
resizable: boolean;
|
||||
sortable: boolean;
|
||||
filter: boolean;
|
||||
minWidth: number;
|
||||
}
|
||||
|
||||
// 로그 관련 타입
|
||||
export interface LogEntry {
|
||||
account: string;
|
||||
datetime: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
// 코드 관련 타입
|
||||
export interface GroupCode {
|
||||
groupCode: string;
|
||||
groupName: string;
|
||||
useYn: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Code {
|
||||
code: string;
|
||||
codeName: string;
|
||||
codeDetail: string;
|
||||
parentCode: string;
|
||||
useYn: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// 프로그램 관련 타입
|
||||
export interface Program {
|
||||
parentCode: string;
|
||||
level: number;
|
||||
code: string;
|
||||
name: string;
|
||||
useYn: boolean;
|
||||
menuYn: boolean;
|
||||
apiYn: boolean;
|
||||
exceptionYn: boolean;
|
||||
order: number;
|
||||
uri: string;
|
||||
field1: string;
|
||||
field2: string;
|
||||
field3: string;
|
||||
}
|
41
utils/formatDate.ts
Normal file
41
utils/formatDate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const formatDate = (
|
||||
date: Date | string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
return dateObj.toLocaleDateString("ko-KR", { ...defaultOptions, ...options });
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (date: Date | string): string => {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return "방금 전";
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes}분 전`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours}시간 전`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays}일 전`;
|
||||
}
|
||||
|
||||
return formatDate(dateObj);
|
||||
};
|
Reference in New Issue
Block a user