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

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>