init source

This commit is contained in:
leejisun9
2025-08-08 13:11:33 +09:00
parent 02660627d2
commit 61d947a644
57 changed files with 1852863 additions and 0 deletions

94
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,6 @@
node_modules
.nuxt
dist
.output
*.log
package-lock.json

11
.prettierrc Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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,
};
};

View 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')
}

View 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
View 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
View 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
View 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>&copy; 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(() => {
// 플러그인이 클라이언트에서만 실행되도록 설정
});

View 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

File diff suppressed because it is too large Load Diff

11011
public/expanded_pathway1.xml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

362086
public/group21600.json Normal file

File diff suppressed because it is too large Load Diff

720646
public/group43200.json Normal file

File diff suppressed because it is too large Load Diff

83
public/jsonpathway.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

10882
public/pon00061.json Normal file

File diff suppressed because it is too large Load Diff

2583
public/pon00061.xml Normal file

File diff suppressed because it is too large Load Diff

39
public/pon00062.xml Normal file
View 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
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View 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}`
});
}
});

View 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
View File

@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

27
stores/counter.ts Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

55
types/ag-grid.ts Normal file
View 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
View 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);
};