parent
6e41bb966b
commit
a11ff9c1d3
@ -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>
|
|
||||||
@ -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,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";
|
||||||
|
};
|
||||||
@ -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,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,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>
|
||||||
Loading…
Reference in new issue