Merge branch 'feature/main-js' of http://192.168.10.110/Autoflow/autoflow-web-console into feature/main-js
commit
82c5bcf2bf
@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { defineEmits } from "vue";
|
||||
|
||||
const emit = defineEmits(["onClick"]);
|
||||
|
||||
const onClick = () => {
|
||||
emit("onClick");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tooltip location="bottom" text="실행">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
@click="onClick"
|
||||
class="ma-1"
|
||||
icon="mdi-cog-play-outline"
|
||||
color="success"
|
||||
density="comfortable"
|
||||
elevation="0"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAutoflowStore } from "@/stores/autoflowStore";
|
||||
import { AttachmentsService } from "@/components/service/management/attachmentsService";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
const props = defineProps<{ editData: any; mode: "create" | "edit" }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close-modal"): void;
|
||||
(e: "saved", v: any): void;
|
||||
}>();
|
||||
|
||||
const isEdit = computed(() => props.mode === "edit");
|
||||
const { projectId } = storeToRefs(useAutoflowStore());
|
||||
const saving = ref(false);
|
||||
const errorMsg = ref("");
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
file: null as any, // File | File[] | null
|
||||
});
|
||||
|
||||
function hydrateFormFromEdit(d: any) {
|
||||
if (!d) return;
|
||||
form.value.name = (d?.name ?? d?.title ?? "") + "";
|
||||
form.value.description = (d?.description ?? "") + "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) hydrateFormFromEdit(props.editData);
|
||||
});
|
||||
watch(
|
||||
() => props.editData,
|
||||
(v) => {
|
||||
if (isEdit.value) hydrateFormFromEdit(v);
|
||||
},
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
isEdit.value ? "Edit Training Script" : "Create Training Script",
|
||||
);
|
||||
|
||||
// 로그인 사용자
|
||||
const regUserId = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("autoflow-auth") || "{}";
|
||||
const auth = JSON.parse(raw);
|
||||
return (
|
||||
auth?.userInfo?.username ??
|
||||
auth?.userinfo?.username ??
|
||||
auth?.username ??
|
||||
auth?.userId ??
|
||||
""
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
// ✅ 여기만 변경
|
||||
async function submit() {
|
||||
errorMsg.value = "";
|
||||
|
||||
const title = (form.value.name || "").trim();
|
||||
const desc = (form.value.description || "").trim();
|
||||
const fileObj = Array.isArray(form.value.file)
|
||||
? form.value.file[0]
|
||||
: form.value.file;
|
||||
|
||||
if (!title) return (errorMsg.value = "Training Script Title은 필수입니다.");
|
||||
if (!regUserId)
|
||||
return (errorMsg.value = "로그인 사용자 정보를 찾을 수 없습니다.");
|
||||
|
||||
try {
|
||||
saving.value = true;
|
||||
|
||||
if (isEdit.value) {
|
||||
if (!fileObj) return (errorMsg.value = "수정할 새 파일을 선택해주세요.");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("title", title);
|
||||
fd.append("description", desc);
|
||||
fd.append("regUserId", regUserId);
|
||||
fd.append("projectId", String(projectId.value));
|
||||
fd.append("file", fileObj);
|
||||
|
||||
const id = props.editData?.id ?? props.editData?.deviceKey;
|
||||
await AttachmentsService.update(id, fd as any);
|
||||
} else {
|
||||
if (!fileObj) return (errorMsg.value = "업로드할 파일을 선택해주세요.");
|
||||
if (!projectId.value)
|
||||
return (errorMsg.value = "프로젝트가 선택되지 않았습니다.");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("refId", "0");
|
||||
fd.append("refType", "DATASET");
|
||||
fd.append("title", title);
|
||||
fd.append("description", desc);
|
||||
fd.append("version", "1");
|
||||
fd.append("regUserId", regUserId);
|
||||
fd.append("projectId", String(projectId.value));
|
||||
fd.append("file", fileObj);
|
||||
|
||||
await AttachmentsService.upload(fd as any);
|
||||
}
|
||||
|
||||
emit("saved", { ok: true });
|
||||
emit("close-modal");
|
||||
} catch (e) {
|
||||
console.error("[Dataset] 저장 실패:", e as AxiosError);
|
||||
errorMsg.value = "저장에 실패했습니다. 잠시 후 다시 시도하세요.";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close-modal");
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onEsc));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="rounded-lg overflow-hidden">
|
||||
<v-card-title
|
||||
class="text-white font-weight-bold text-h6"
|
||||
style="background-color: #1976d2"
|
||||
>
|
||||
{{ dialogTitle }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form @submit.prevent="submit">
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>Training Script Title</label
|
||||
>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
variant="outlined"
|
||||
:disabled="saving"
|
||||
dense
|
||||
hide-details
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>Description</label
|
||||
>
|
||||
<v-text-field
|
||||
v-model="form.description"
|
||||
variant="outlined"
|
||||
:disabled="saving"
|
||||
dense
|
||||
hide-details
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>File</label
|
||||
>
|
||||
<v-file-input
|
||||
v-model="form.file"
|
||||
label="Upload File"
|
||||
:disabled="saving"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
||||
<v-btn color="success" :loading="saving" @click="submit">
|
||||
{{ isEdit ? "Update" : "Save" }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
class="white--text"
|
||||
:disabled="saving"
|
||||
@click="$emit('close-modal')"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -1,110 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
editData: Object,
|
||||
mode: String,
|
||||
userOption: Array,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["handle-data", "close-modal"]);
|
||||
|
||||
const visible = ref(true);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const form = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
file: "",
|
||||
});
|
||||
|
||||
// 다이얼로그 타이틀
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.mode === "create") return "Create Dataset";
|
||||
if (props.mode === "edit") return "Edit Dataset";
|
||||
return "Clone Execution";
|
||||
});
|
||||
|
||||
const onChooseFile = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
const submit = () => {
|
||||
emit("handle-data", form.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="rounded-lg overflow-hidden">
|
||||
<!-- 타이틀 영역 -->
|
||||
<v-card-title
|
||||
class="text-white font-weight-bold text-h6"
|
||||
style="background-color: #1976d2"
|
||||
>
|
||||
{{ dialogTitle }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form @submit.prevent="submit">
|
||||
<v-row dense class="mb-6">
|
||||
<v-col cols="6">
|
||||
<v-subheader class="font-weight-medium white--text mb-2">
|
||||
Dataset Title
|
||||
</v-subheader>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
variant="outlined"
|
||||
dense
|
||||
hide-details
|
||||
outlined
|
||||
style="background: #1e1e1e; color: #fff"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-subheader class="font-weight-medium white--text mb-2">
|
||||
Dataset Version
|
||||
</v-subheader>
|
||||
<v-text-field
|
||||
variant="outlined"
|
||||
dense
|
||||
hide-details
|
||||
outlined
|
||||
style="background: #1e1e1e; color: #fff"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>Description
|
||||
</label>
|
||||
<v-text-field
|
||||
v-model="form.description"
|
||||
variant="outlined"
|
||||
dense
|
||||
hide-details
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
||||
>Upload File
|
||||
</label>
|
||||
<v-file-input
|
||||
v-model="form.file"
|
||||
label="Upload File"
|
||||
@click:append-outer="onChooseFile"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
||||
<v-btn color="success" @click="submit">Save</v-btn>
|
||||
<v-btn text class="white--text" @click="$emit('close-modal')"
|
||||
>Close</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -1,99 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits } from "vue";
|
||||
|
||||
// 부모에서 전달받을 Props 정의
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
selectedData: { workflow: string; stepName: string } | null;
|
||||
workflowList: string[];
|
||||
}>();
|
||||
|
||||
// v-model 및 save 이벤트를 위한 Emit 정의
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", payload: { workflow: string; stepName: string }): void;
|
||||
}>();
|
||||
|
||||
// 다이얼로그 내부에서 사용할 로컬 상태
|
||||
const internalWorkflow = ref(props.selectedData?.workflow || "");
|
||||
const internalStepName = ref(props.selectedData?.stepName || "");
|
||||
|
||||
// 다이얼로그가 열릴 때 외부 데이터를 내부로 복사
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && props.selectedData) {
|
||||
internalWorkflow.value = props.selectedData.workflow;
|
||||
internalStepName.value = props.selectedData.stepName;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Save 버튼 클릭
|
||||
const onSave = () => {
|
||||
emit("save", {
|
||||
workflow: internalWorkflow.value,
|
||||
stepName: internalStepName.value,
|
||||
});
|
||||
emit("update:modelValue", false);
|
||||
};
|
||||
|
||||
// Close 버튼 클릭
|
||||
const onClose = () => {
|
||||
emit("update:modelValue", false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="rounded-lg overflow-hidden">
|
||||
<!-- 타이틀 바 -->
|
||||
|
||||
<v-card-title
|
||||
class="text-white font-weight-bold text-h6"
|
||||
style="background-color: #1976d2"
|
||||
>Edit Workflow Step Config</v-card-title
|
||||
>
|
||||
|
||||
<!-- 본문 -->
|
||||
<v-card-text class="pt-6 px-6 pb-4">
|
||||
<!-- Select Workflow -->
|
||||
<v-row class="mb-6" dense>
|
||||
<v-col cols="12" sm="4">
|
||||
<div class="font-weight-medium white--text">Select Workflow</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-select
|
||||
v-model="internalWorkflow"
|
||||
:items="workflowList"
|
||||
dense
|
||||
hide-details
|
||||
placeholder="Select Workflow"
|
||||
style="background: #1e1e1e; color: #fff"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Workflow Step Name -->
|
||||
<v-row class="mb-6" dense>
|
||||
<v-col cols="12">
|
||||
<div class="font-weight-medium white--text mb-2">
|
||||
Workflow Step Name
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="internalStepName"
|
||||
dense
|
||||
hide-details
|
||||
placeholder="Enter Workflow Step"
|
||||
style="background: #1e1e1e; color: #fff"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
||||
<v-btn color="success" @click="onSave">Save</v-btn>
|
||||
<v-btn text class="white--text" @click="onClose">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { kubeflowService } from "@/components/service/management/kubeflowService";
|
||||
|
||||
type RunPayload = {
|
||||
display_name: string;
|
||||
description?: string;
|
||||
pipeline_version_reference: { pipeline_id: string };
|
||||
runtime_config?: { parameters?: Record<string, any> };
|
||||
service_account?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
/** 테이블에서 선택된 파이프라인의 pipelineId */
|
||||
pipelineId?: string | number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close-modal"): void;
|
||||
(e: "submitted", value: any): void;
|
||||
}>();
|
||||
|
||||
const form = ref({
|
||||
display_name: "", // ✅ 빈 값으로 시작
|
||||
description: "", // ✅ 빈 값으로 시작
|
||||
pipeline_id: "", // prop으로만 채움(읽기 전용)
|
||||
});
|
||||
const loading = ref(false);
|
||||
const errorMsg = ref("");
|
||||
|
||||
const isValid = computed(
|
||||
() => !!form.value.display_name.trim() && !!form.value.pipeline_id.trim(),
|
||||
);
|
||||
|
||||
function initForm() {
|
||||
form.value.pipeline_id = props.pipelineId ? String(props.pipelineId) : "";
|
||||
// display_name/description은 비워둠
|
||||
}
|
||||
|
||||
onMounted(initForm);
|
||||
watch(() => props.pipelineId, initForm);
|
||||
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !loading.value) emit("close-modal");
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onEsc));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
|
||||
|
||||
async function submitRun() {
|
||||
errorMsg.value = "";
|
||||
if (!isValid.value) {
|
||||
errorMsg.value = "Run 제목(display_name)과 pipeline_id는 필수입니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: RunPayload = {
|
||||
display_name: form.value.display_name.trim(),
|
||||
description: form.value.description?.trim(),
|
||||
pipeline_version_reference: { pipeline_id: form.value.pipeline_id.trim() },
|
||||
runtime_config: { parameters: {} }, // 필요 시 파라미터 매핑
|
||||
service_account: "pipeline-runner",
|
||||
};
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const { data } = await kubeflowService.run(payload);
|
||||
emit("submitted", data);
|
||||
emit("close-modal");
|
||||
} catch (e: any) {
|
||||
console.error("Run 생성 실패:", e);
|
||||
const msg =
|
||||
e?.response?.data?.message ||
|
||||
e?.response?.data?.error ||
|
||||
e?.message ||
|
||||
"Run 생성에 실패했습니다.";
|
||||
errorMsg.value = String(msg);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="text-white font-weight-bold text-h6"
|
||||
style="background-color: #1976d2"
|
||||
>
|
||||
Run Pipeline
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-form @submit.prevent="submitRun">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
|
||||
Run Title (display_name)
|
||||
</label>
|
||||
<v-text-field
|
||||
v-model="form.display_name"
|
||||
variant="outlined"
|
||||
:disabled="loading"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
persistent-hint
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
|
||||
Run Description
|
||||
</label>
|
||||
<v-textarea
|
||||
v-model="form.description"
|
||||
variant="outlined"
|
||||
:disabled="loading"
|
||||
rows="3"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- pipeline_id -->
|
||||
<div class="mb-2">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
|
||||
pipeline_id
|
||||
</label>
|
||||
<v-text-field
|
||||
v-model="form.pipeline_id"
|
||||
variant="outlined"
|
||||
:disabled="true"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
||||
<v-btn
|
||||
color="success"
|
||||
:loading="loading"
|
||||
:disabled="!isValid"
|
||||
@click="submitRun"
|
||||
>
|
||||
RUN
|
||||
</v-btn>
|
||||
<v-btn text :disabled="loading" @click="$emit('close-modal')"
|
||||
>CLOSE</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import logo from "@/assets/iteration (1).png";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column align-center pt-6">
|
||||
<v-img :src="logo" width="auto" height="36" class="mb-3" />
|
||||
<div class="text-subtitle-2 font-weight-medium text-primary">
|
||||
Autoflow Web Console
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,24 @@
|
||||
export type AttachmentUpload = {
|
||||
refId?: number | null;
|
||||
refType: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: number;
|
||||
regUserId: string;
|
||||
projectId: number;
|
||||
file: File | Blob;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type AttachmentSearch = {
|
||||
projectId: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
searchType?: "전체" | "제목" | "작성자";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortField?: string;
|
||||
sortDirection?: "ASC" | "DESC";
|
||||
refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT";
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
export interface Workflow {
|
||||
workflowName: string;
|
||||
workflowDescription?: string;
|
||||
uploadYn: "Y" | "N";
|
||||
regUserId: string;
|
||||
regDt: string;
|
||||
modDt: string;
|
||||
projectId: number;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
export interface ExperimentCreateDto {
|
||||
kubeFlowId?: string;
|
||||
mlFlowId?: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
artifactLocation?: string;
|
||||
lifecycleStage?: string;
|
||||
storageState?: string;
|
||||
kubeflowCreatedAt?: string;
|
||||
mlflowCreatedAt?: string;
|
||||
lastUpdateTime?: string;
|
||||
lastRunCreatedAt?: string;
|
||||
regUserId: string;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
export type ExperimentSearch = {
|
||||
projectId: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
searchType?: "전체" | "제목" | "작성자";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortField?: string;
|
||||
sortDirection?: "ASC" | "DESC";
|
||||
refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT";
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
export type KubeflowUploadDto = {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
regUserId: string;
|
||||
projectId: number | string;
|
||||
uploadfile: File | Blob;
|
||||
};
|
||||
|
||||
export type kubeflow = FormData;
|
||||
|
||||
export function toKubeflowForm(dto: KubeflowUploadDto): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append("name", dto.name);
|
||||
fd.append("display_name", dto.display_name || dto.name);
|
||||
fd.append("description", dto.description || "");
|
||||
fd.append("namespace", dto.namespace || "default");
|
||||
fd.append("regUserId", String(dto.regUserId));
|
||||
fd.append("projectId", String(dto.projectId));
|
||||
fd.append("uploadfile", dto.uploadfile);
|
||||
return fd;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
export interface Workflow {
|
||||
workflowName: string;
|
||||
workflowDescription?: string;
|
||||
uploadYn: "Y" | "N";
|
||||
regUserId: string;
|
||||
regDt: string;
|
||||
modDt: string;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
export interface WorkflowSearch {
|
||||
projectId: number; // ✅ 유일한 필수
|
||||
page?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
searchType?: "전체" | "제목" | "작성자";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortField?: string;
|
||||
sortDirection?: "ASC" | "DESC";
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
export type StepStatus = "Running" | "Success" | "Fail";
|
||||
|
||||
export interface WorkflowStep {
|
||||
projectId: number;
|
||||
stepName: string;
|
||||
status?: StepStatus;
|
||||
pipelineId?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
logPath?: string;
|
||||
version?: string;
|
||||
|
||||
files?: Array<{
|
||||
refType?: "workflow_step";
|
||||
originalName: string;
|
||||
storageName: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
storagePath: string;
|
||||
}>;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import { Workflow } from "@/components/models/management/Autoflow";
|
||||
import { request } from "@/components/service/index";
|
||||
export const AutoflowStepService = {
|
||||
add: (payload: Workflow) => {
|
||||
return request.post("/api/workflow-steps", payload);
|
||||
},
|
||||
getAll: () => {
|
||||
request.get("/api/workflow-steps", {});
|
||||
},
|
||||
|
||||
delete: (id: Number) => {
|
||||
return request.delete(`/api/workflow-steps${id}`, {});
|
||||
},
|
||||
view: (id: Number) => {
|
||||
return request.get(`/api/workflow-steps${id}`, {});
|
||||
},
|
||||
update: (id: number, payload: Workflow) => {
|
||||
return request.put(`/api/workflow-steps${id}`, payload);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
import {
|
||||
AttachmentSearch,
|
||||
AttachmentUpload,
|
||||
} from "@/components/models/management/Attachments";
|
||||
|
||||
import { request } from "@/components/service/index";
|
||||
export const AttachmentsService = {
|
||||
upload: (payload: AttachmentUpload) => {
|
||||
return request.post("/api/attachments/upload", payload);
|
||||
},
|
||||
delete: (id: Number) => {
|
||||
return request.delete(`/api/attachments/${id}`, {});
|
||||
},
|
||||
view: (id: number) => {
|
||||
return request.get(`/api/attachments/${id}`, {});
|
||||
},
|
||||
update: (id: number, payload: AttachmentUpload) => {
|
||||
return request.put(`/api/attachments/${id}/update`, payload);
|
||||
},
|
||||
|
||||
readTextByPath: (objectName: string) => {
|
||||
return request.get(
|
||||
`/api/attachments/readYamlText?objectName=${objectName}`,
|
||||
{},
|
||||
);
|
||||
},
|
||||
downloadFile: (objectName: string) => {
|
||||
return request.getFile(
|
||||
`/api/attachments/download?objectName=${objectName}`,
|
||||
{},
|
||||
);
|
||||
},
|
||||
search: (payload: AttachmentSearch) => {
|
||||
return request.get("/api/attachments/search", payload);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import {
|
||||
ExperimentCreateDto,
|
||||
ExperimentSearch,
|
||||
} from "@/components/models/management/Experiments";
|
||||
import { request } from "@/components/service/index";
|
||||
export const ExperimentService = {
|
||||
add: (payload: ExperimentCreateDto) => {
|
||||
return request.post("/api/experiments", payload);
|
||||
},
|
||||
delete: (id: Number) => {
|
||||
return request.delete(`/api/experiments/${id}`, {});
|
||||
},
|
||||
view: (id: number) => {
|
||||
return request.get(`/api/experiments/${id}`, {});
|
||||
},
|
||||
// update: (id: number, payload: AttachmentUpload) => {
|
||||
// return request.put(`/api/experiments/${id}`, payload);
|
||||
// },
|
||||
search: (payload: ExperimentSearch) => {
|
||||
return request.get("/api/experiments/search", payload);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { kubeflow } from "@/components/models/management/Kubeflow";
|
||||
import { request } from "@/components/service/index";
|
||||
export const kubeflowService = {
|
||||
upload: (payload: kubeflow) => {
|
||||
return request.post("/pipelines/upload", payload);
|
||||
},
|
||||
run: (payload: kubeflow) => {
|
||||
return request.post("/pipelines/runs", payload);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { request } from "@/components/service/index";
|
||||
import { WorkflowStep } from "@/components/models/management/WorkflowStep";
|
||||
import { WorkflowSearch } from "@/components/models/management/Workflow";
|
||||
export const WorkflowStepService = {
|
||||
add: (payload: WorkflowStep) => {
|
||||
return request.post("/api/workflow-steps", payload);
|
||||
},
|
||||
getAll: (params?: Record<string, any>) => {
|
||||
return request.get("/api/workflow-steps", { params });
|
||||
},
|
||||
delete: (id: number) => {
|
||||
return request.delete(`/api/workflow-steps/${id}`, {});
|
||||
},
|
||||
view: (id: number) => {
|
||||
return request.get(`/api/workflow-steps/${id}`, {});
|
||||
},
|
||||
update: (id: number, payload: WorkflowStep) => {
|
||||
return request.put(`/api/workflow-steps/${id}`, payload);
|
||||
},
|
||||
search: (payload: WorkflowSearch) => {
|
||||
return request.get("/api/workflow-steps/search", payload);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,669 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from "vue";
|
||||
import { commonStore } from "@/stores/commonStore";
|
||||
import { storage } from "@/utils/storage.js";
|
||||
import { ProjectService } from "@/components/service/project/projectService";
|
||||
import { UserManagerService } from "@/components/service/management/userManagerService";
|
||||
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
|
||||
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
|
||||
|
||||
/** ---------- 상수/상태 ---------- */
|
||||
const store = commonStore();
|
||||
|
||||
const roleOptions = ["ROLE_USER", "ROLE_MODERATOR", "ROLE_ADMIN"] as const;
|
||||
|
||||
type SearchType = "전체" | "제목" | "작성자";
|
||||
const searchOptions = [
|
||||
{ label: "전체", value: "전체" as SearchType },
|
||||
{ label: "제목", value: "제목" as SearchType },
|
||||
{ label: "작성자", value: "작성자" as SearchType },
|
||||
];
|
||||
const SEARCH_TYPE_MAP: Record<SearchType | "", "ALL" | "TITLE" | "AUTHOR"> = {
|
||||
"": "ALL",
|
||||
전체: "ALL",
|
||||
제목: "TITLE",
|
||||
작성자: "AUTHOR",
|
||||
};
|
||||
|
||||
const fmtDate = (v?: string) => (v ? v.replace("T", " ").slice(0, 19) : "-");
|
||||
const splitCsv = (v?: string) =>
|
||||
String(v ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** 로그인한 사용자의 권한 (필요 시) */
|
||||
const roles = ref<string[]>([]);
|
||||
const refreshRoles = () => {
|
||||
const auth = storage.getAuth?.() ?? storage.get?.("vpp-Auth") ?? null;
|
||||
const r = auth?.userInfo?.roles ?? auth?.roles ?? [];
|
||||
roles.value = Array.isArray(r) ? r : [];
|
||||
};
|
||||
const isAdmin = computed(() => roles.value.includes("ROLE_ADMIN"));
|
||||
|
||||
/** 테이블 정의 */
|
||||
const tableHeader = [
|
||||
{ label: "No", width: "6%", style: "word-break: keep-all;" },
|
||||
{ label: "Username", width: "10%", style: "word-break: keep-all;" },
|
||||
{ label: "Email", width: "20%", style: "word-break: keep-all;" },
|
||||
{ label: "Roles", width: "27%", style: "word-break: keep-all;" },
|
||||
{ label: "Projects", width: "27%", style: "word-break: keep-all;" },
|
||||
{ label: "Action", width: "10%", style: "word-break: keep-all;" },
|
||||
];
|
||||
|
||||
const pageSizeOptions = [
|
||||
{ text: "10 페이지", value: 10 },
|
||||
{ text: "50 페이지", value: 50 },
|
||||
{ text: "100 페이지", value: 100 },
|
||||
];
|
||||
|
||||
/** ---------- 타입 ---------- */
|
||||
type Row = {
|
||||
no: number;
|
||||
name: string; // username
|
||||
desc: string; // email
|
||||
users: string[]; // roles
|
||||
projects: string[]; // project names
|
||||
registDt: string;
|
||||
deviceKey: number; // user id
|
||||
};
|
||||
|
||||
type SelectedUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string; // 단일
|
||||
} | null;
|
||||
|
||||
/** ---------- 상태 ---------- */
|
||||
const data = ref({
|
||||
params: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
searchType: "전체" as SearchType,
|
||||
searchText: "",
|
||||
},
|
||||
results: [] as Row[],
|
||||
totalDataLength: 0,
|
||||
pageLength: 0,
|
||||
|
||||
modalMode: "" as "create" | "edit" | "",
|
||||
selectedData: null as SelectedUser,
|
||||
|
||||
allSelected: false,
|
||||
selected: [] as Array<{ deviceKey: number }>,
|
||||
|
||||
isCreateVisible: false,
|
||||
isConfirmDialogVisible: false,
|
||||
});
|
||||
|
||||
/** 사용자 폼: Roles 단일 선택 */
|
||||
const userForm = ref({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
roles: "" as (typeof roleOptions)[number] | "", // 단일
|
||||
});
|
||||
const resetUserForm = () => {
|
||||
userForm.value = { username: "", email: "", password: "", roles: "" };
|
||||
};
|
||||
|
||||
/** ---------- 목록/검색 ---------- */
|
||||
function toRow(u: any, no: number, projectNames: string[] = []): Row {
|
||||
const rolesArr = Array.isArray(u?.roles)
|
||||
? u.roles
|
||||
: typeof u?.roles === "string"
|
||||
? splitCsv(u.roles)
|
||||
: [];
|
||||
|
||||
return {
|
||||
no,
|
||||
name: u?.username ?? u?.name ?? "-",
|
||||
desc: u?.email ?? "-", // 이메일 우선
|
||||
users: rolesArr,
|
||||
projects: projectNames,
|
||||
registDt: fmtDate(u?.createdAt ?? u?.regDate),
|
||||
deviceKey: Number(u?.id),
|
||||
};
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
const { pageNum, pageSize, searchType, searchText } = data.value.params;
|
||||
const mapped = SEARCH_TYPE_MAP[searchType] || "ALL";
|
||||
const keyword = (searchText || "").trim().toLowerCase();
|
||||
|
||||
try {
|
||||
// 1) 사용자 전체 조회
|
||||
const res = await UserManagerService.getAll();
|
||||
let list: any[] = Array.isArray(res?.data) ? res.data : [];
|
||||
|
||||
// 2) 프론트 검색
|
||||
if (keyword) {
|
||||
list = list.filter((u) => {
|
||||
const username = String(u?.username ?? u?.name ?? "").toLowerCase();
|
||||
const email = String(u?.email ?? "").toLowerCase();
|
||||
const rolesStr = Array.isArray(u?.roles)
|
||||
? u.roles.join(",").toLowerCase()
|
||||
: String(u?.roles ?? "").toLowerCase();
|
||||
|
||||
if (mapped === "TITLE") return username.includes(keyword);
|
||||
if (mapped === "AUTHOR")
|
||||
return email.includes(keyword) || rolesStr.includes(keyword);
|
||||
return (
|
||||
username.includes(keyword) ||
|
||||
email.includes(keyword) ||
|
||||
rolesStr.includes(keyword)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 3) 정렬 & 페이징
|
||||
list.sort((a, b) => (Number(b?.id) || 0) - (Number(a?.id) || 0));
|
||||
const totalElements = list.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalElements / pageSize));
|
||||
const safePage = Math.min(Math.max(1, pageNum), totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
const pageSlice = list.slice(start, start + pageSize);
|
||||
const firstNo = totalElements - start;
|
||||
|
||||
// 4) 프로젝트는 나중에 병렬로 가져와 반영
|
||||
data.value.results = pageSlice.map((u: any, i: number) =>
|
||||
toRow(u, Math.max(1, firstNo - i), []),
|
||||
);
|
||||
data.value.totalDataLength = totalElements;
|
||||
data.value.pageLength = totalPages;
|
||||
|
||||
const projectLists = await Promise.all(
|
||||
pageSlice.map((u) =>
|
||||
ProjectService.userProjectAuthority(Number(u?.id))
|
||||
.then((r: any) => (Array.isArray(r?.data) ? r.data : []))
|
||||
.catch(() => []),
|
||||
),
|
||||
);
|
||||
|
||||
data.value.results = pageSlice.map((u: any, i: number) => {
|
||||
const projs = projectLists[i] || [];
|
||||
const names = projs
|
||||
.map((p: any) => String(p?.projectName ?? ""))
|
||||
.filter(Boolean);
|
||||
return toRow(u, Math.max(1, firstNo - i), names);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Users] fetch error:", e);
|
||||
data.value.results = [];
|
||||
data.value.totalDataLength = 0;
|
||||
data.value.pageLength = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** ---------- 페이지/검색 트리거 ---------- */
|
||||
function doSearch() {
|
||||
data.value.params.pageNum = 1;
|
||||
getData();
|
||||
}
|
||||
function changePageSize(size: number) {
|
||||
data.value.params.pageSize = size;
|
||||
data.value.params.pageNum = 1;
|
||||
getData();
|
||||
}
|
||||
function changePageNum(page: number) {
|
||||
data.value.params.pageNum = page;
|
||||
getData();
|
||||
}
|
||||
watch(
|
||||
() => data.value.params.searchType,
|
||||
() => doSearch(),
|
||||
);
|
||||
|
||||
/** ---------- 모달 열기/닫기 (워크플로우 패턴) ---------- */
|
||||
const openCreateModal = () => {
|
||||
data.value.selectedData = null;
|
||||
data.value.modalMode = "create";
|
||||
resetUserForm();
|
||||
data.value.isCreateVisible = true;
|
||||
};
|
||||
|
||||
const openModifyModal = (row: Row) => {
|
||||
data.value.selectedData = {
|
||||
id: row.deviceKey,
|
||||
username: row.name,
|
||||
email: row.desc === "-" ? "" : row.desc,
|
||||
role: row.users?.[0] || "",
|
||||
};
|
||||
data.value.modalMode = "edit";
|
||||
|
||||
// 프리필
|
||||
userForm.value.username = row.name || "";
|
||||
userForm.value.email = row.desc === "-" ? "" : row.desc || "";
|
||||
userForm.value.password = ""; // 수정 시 비워둠
|
||||
userForm.value.roles = (row.users?.[0] as any) || "";
|
||||
|
||||
data.value.isCreateVisible = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
data.value.isCreateVisible = false;
|
||||
};
|
||||
|
||||
/** 모달 열림/닫힘 감시 → 닫힐 때 목록 갱신 */
|
||||
watch(
|
||||
() => data.value.isCreateVisible,
|
||||
(now, prev) => {
|
||||
if (prev && !now) getData();
|
||||
},
|
||||
);
|
||||
|
||||
/** ---------- 저장(생성/수정) ---------- */
|
||||
async function saveUser() {
|
||||
try {
|
||||
const username = userForm.value.username.trim();
|
||||
const password = (userForm.value.password || "").trim();
|
||||
const email = (userForm.value.email || "").trim();
|
||||
const roleOne = userForm.value.roles || "";
|
||||
|
||||
if (!username || (data.value.modalMode === "create" && !password)) {
|
||||
return store.setSnackbarMsg?.({
|
||||
color: "warning",
|
||||
text: "Username은 필수이며, 생성 시 Password도 필요합니다.",
|
||||
result: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
username,
|
||||
email,
|
||||
role: roleOne ? [roleOne] : undefined, // 백엔드가 배열로 받는 케이스 대비
|
||||
};
|
||||
if (password) payload.password = password; // 수정 시 비워두면 미변경
|
||||
|
||||
if (data.value.modalMode === "create") {
|
||||
await UserManagerService.signUp(payload);
|
||||
store.setSnackbarMsg?.({
|
||||
color: "success",
|
||||
text: "계정이 생성되었습니다.",
|
||||
result: 200,
|
||||
});
|
||||
} else {
|
||||
const id = Number(data.value.selectedData?.id);
|
||||
if (!id) {
|
||||
return store.setSnackbarMsg?.({
|
||||
color: "warning",
|
||||
text: "수정할 사용자 ID가 없습니다.",
|
||||
result: 400,
|
||||
});
|
||||
}
|
||||
// 서비스에 update(id, body) 메서드가 있어야 합니다.
|
||||
await UserManagerService.update(id, payload);
|
||||
store.setSnackbarMsg?.({
|
||||
color: "success",
|
||||
text: "수정되었습니다.",
|
||||
result: 200,
|
||||
});
|
||||
}
|
||||
|
||||
await getData();
|
||||
data.value.isCreateVisible = false;
|
||||
} catch (e: any) {
|
||||
console.error("[User] save error:", e?.response?.data || e);
|
||||
store.setSnackbarMsg?.({
|
||||
color: "warning",
|
||||
text:
|
||||
e?.response?.data?.message || e?.response?.data?.error || "요청 실패",
|
||||
result: e?.response?.status || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** ---------- 삭제 ---------- */
|
||||
function getSelectedAllData() {
|
||||
data.value.selected = data.value.allSelected
|
||||
? data.value.results.map((r) => ({ deviceKey: r.deviceKey }))
|
||||
: [];
|
||||
}
|
||||
|
||||
async function deleteRows(targetList?: Array<{ deviceKey: number }>) {
|
||||
const removeList = targetList ?? data.value.selected;
|
||||
if (!removeList?.length) return;
|
||||
|
||||
const ids = removeList.map((x) => x.deviceKey);
|
||||
const remove = (id: number) =>
|
||||
UserManagerService.delete(id).then((res) => {
|
||||
if (res.status < 200 || res.status >= 300) return Promise.reject(res);
|
||||
});
|
||||
|
||||
const after = async () => {
|
||||
if (
|
||||
ids.length >= data.value.results.length &&
|
||||
data.value.params.pageNum > 1
|
||||
) {
|
||||
data.value.params.pageNum -= 1;
|
||||
}
|
||||
await getData();
|
||||
data.value.isConfirmDialogVisible = false;
|
||||
data.value.selected = [];
|
||||
data.value.allSelected = false;
|
||||
};
|
||||
|
||||
if (ids.length === 1) {
|
||||
try {
|
||||
await remove(ids[0]);
|
||||
store.setSnackbarMsg?.({
|
||||
color: "success",
|
||||
text: "삭제되었습니다.",
|
||||
result: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
store.setSnackbarMsg?.({
|
||||
color: "warning",
|
||||
text: "삭제 실패",
|
||||
result: 500,
|
||||
});
|
||||
console.error(err);
|
||||
} finally {
|
||||
after();
|
||||
}
|
||||
} else {
|
||||
Promise.all(ids.map(remove))
|
||||
.then(() =>
|
||||
store.setSnackbarMsg?.({
|
||||
color: "success",
|
||||
text: "모두 삭제되었습니다.",
|
||||
result: 200,
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
store.setSnackbarMsg?.({
|
||||
color: "warning",
|
||||
text: "일부 삭제 실패",
|
||||
result: 500,
|
||||
});
|
||||
console.error(err);
|
||||
})
|
||||
.finally(after);
|
||||
}
|
||||
}
|
||||
|
||||
/** ---------- 마운트 ---------- */
|
||||
onMounted(async () => {
|
||||
refreshRoles();
|
||||
await getData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
|
||||
<v-card
|
||||
flat
|
||||
class="bg-shades-transparent d-flex flex-column align-center justify-center w-100"
|
||||
>
|
||||
<!-- 헤더 -->
|
||||
<v-card flat class="bg-shades-transparent w-100">
|
||||
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
|
||||
<div class="d-flex flex-row justify-start align-center">
|
||||
<div class="text-primary">Users</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
</v-card>
|
||||
|
||||
<!-- 검색/페이지 -->
|
||||
<v-card flat class="bg-shades-transparent w-100">
|
||||
<v-card flat class="bg-shades-transparent mb-4">
|
||||
<div class="d-flex justify-center flex-wrap align-center">
|
||||
<v-responsive
|
||||
max-width="180"
|
||||
min-width="180"
|
||||
class="mr-3 mt-3 mb-3"
|
||||
>
|
||||
<v-select
|
||||
v-model="data.params.searchType"
|
||||
label="검색조건"
|
||||
density="compact"
|
||||
:items="searchOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
hide-details
|
||||
/>
|
||||
</v-responsive>
|
||||
|
||||
<v-responsive min-width="540" max-width="540">
|
||||
<v-text-field
|
||||
v-model="data.params.searchText"
|
||||
label="검색어"
|
||||
density="compact"
|
||||
clearable
|
||||
required
|
||||
class="mt-3 mb-3"
|
||||
hide-details
|
||||
@keyup.enter="doSearch"
|
||||
/>
|
||||
</v-responsive>
|
||||
|
||||
<div class="ml-3">
|
||||
<v-btn
|
||||
size="large"
|
||||
color="primary"
|
||||
:rounded="5"
|
||||
@click="doSearch"
|
||||
>
|
||||
<v-icon>mdi-magnify</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- 상단 툴바 -->
|
||||
<v-sheet
|
||||
class="bg-shades-transparent d-flex flex-wrap align-center mb-2"
|
||||
>
|
||||
<v-sheet class="d-flex flex-wrap me-auto bg-shades-transparent">
|
||||
<v-sheet
|
||||
class="d-flex align-center mr-3 mb-2 bg-shades-transparent"
|
||||
>
|
||||
<v-chip color="primary"
|
||||
>총 {{ data.totalDataLength.toLocaleString() }}개</v-chip
|
||||
>
|
||||
</v-sheet>
|
||||
|
||||
<v-sheet class="bg-shades-transparent">
|
||||
<v-responsive max-width="140" min-width="140" class="mb-2">
|
||||
<v-select
|
||||
v-model="data.params.pageSize"
|
||||
density="compact"
|
||||
:items="pageSizeOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
hide-details
|
||||
@update:model-value="changePageSize"
|
||||
/>
|
||||
</v-responsive>
|
||||
</v-sheet>
|
||||
</v-sheet>
|
||||
|
||||
<v-sheet class="justify-end mb-2">
|
||||
<v-btn color="info" @click="openCreateModal">Create User</v-btn>
|
||||
</v-sheet>
|
||||
</v-sheet>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<v-card class="rounded-lg pa-8">
|
||||
<v-col cols="12">
|
||||
<v-sheet>
|
||||
<v-table
|
||||
density="comfortable"
|
||||
fixed-header
|
||||
height="625"
|
||||
overflow-x-auto
|
||||
>
|
||||
<colgroup>
|
||||
<col style="width: 5%" />
|
||||
<col
|
||||
v-for="(item, i) in tableHeader"
|
||||
:key="i"
|
||||
:style="`width:${item.width}`"
|
||||
/>
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<v-checkbox
|
||||
v-model="data.allSelected"
|
||||
style="min-width: 36px"
|
||||
:indeterminate="data.allSelected === true"
|
||||
hide-details
|
||||
@change="getSelectedAllData"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
v-for="(item, i) in tableHeader"
|
||||
:key="i"
|
||||
class="text-center font-weight-bold"
|
||||
:style="`${item.style}`"
|
||||
>
|
||||
{{ item.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="text-body-2">
|
||||
<tr
|
||||
v-for="(item, i) in data.results"
|
||||
:key="i"
|
||||
class="text-center"
|
||||
>
|
||||
<td>
|
||||
<v-checkbox
|
||||
v-model="data.selected"
|
||||
hide-details
|
||||
:value="{ deviceKey: item.deviceKey }"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>{{ item.no }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
|
||||
<td>
|
||||
<div class="truncate-2">{{ item.desc || "-" }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<template v-if="item.users?.length">
|
||||
<v-chip
|
||||
v-for="u in item.users"
|
||||
:key="u"
|
||||
size="small"
|
||||
class="ma-1"
|
||||
color="blue-lighten-2"
|
||||
text-color="white"
|
||||
>
|
||||
{{ u }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<template v-if="item.projects?.length">
|
||||
<v-chip
|
||||
v-for="p in item.projects"
|
||||
:key="p"
|
||||
size="small"
|
||||
class="ma-1"
|
||||
color="purple-lighten-2"
|
||||
text-color="white"
|
||||
>
|
||||
{{ p }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
|
||||
<td style="white-space: nowrap">
|
||||
<IconModifyBtn @on-click="openModifyModal(item)" />
|
||||
<IconDeleteBtn
|
||||
@on-click="
|
||||
deleteRows([{ deviceKey: item.deviceKey }])
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-sheet>
|
||||
|
||||
<v-card-actions class="text-center mt-8 justify-center">
|
||||
<v-pagination
|
||||
v-model="data.params.pageNum"
|
||||
:length="data.pageLength"
|
||||
:total-visible="10"
|
||||
color="primary"
|
||||
rounded="circle"
|
||||
@update:model-value="changePageNum"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-col>
|
||||
</v-card>
|
||||
</v-card>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 생성/수정 모달 -->
|
||||
<v-dialog
|
||||
v-model="data.isCreateVisible"
|
||||
max-width="560"
|
||||
:persistent="false"
|
||||
:close-on-esc="true"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
{{ data.modalMode === "create" ? "Create User" : "Modify User" }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-text-field
|
||||
label="Username"
|
||||
v-model="userForm.username"
|
||||
:disabled="data.modalMode === 'edit'"
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
label="Email"
|
||||
type="email"
|
||||
v-model="userForm.email"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<v-text-field
|
||||
label="Password"
|
||||
type="password"
|
||||
v-model="userForm.password"
|
||||
:required="data.modalMode === 'create'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<v-select
|
||||
label="Roles"
|
||||
v-model="userForm.roles"
|
||||
:items="roleOptions"
|
||||
:multiple="false"
|
||||
clearable
|
||||
chips
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" @click="saveUser">
|
||||
{{ data.modalMode === "create" ? "Create" : "Save" }}
|
||||
</v-btn>
|
||||
<v-btn text @click="closeCreateModal">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import ListComponent from "@/components/templates/users/ListComponent.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListComponent />
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass"></style>
|
||||
Loading…
Reference in new issue