|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
|
|
|
import { AttachmentsService } from "@/components/service/management/attachmentsService";
|
|
|
|
|
import { ProjectService } from "@/components/service/project/projectService";
|
|
|
|
|
// ✅ 부모에서 넘겨준 id 수신 + 닫기 이벤트
|
|
|
|
|
const props = defineProps<{ id: number | string }>();
|
|
|
|
|
const emit = defineEmits<{ (e: "close"): void }>();
|
|
|
|
|
|
|
|
|
|
// -------- state --------
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
const detailRaw = ref<any | null>(null);
|
|
|
|
|
|
|
|
|
|
const experimentInfo = ref({
|
|
|
|
|
datasetTitle: "-",
|
|
|
|
|
projectName: "-",
|
|
|
|
|
version: "-",
|
|
|
|
|
createdDate: "-",
|
|
|
|
|
createdId: "-",
|
|
|
|
|
modifiedDate: "-",
|
|
|
|
|
modifiedId: "-",
|
|
|
|
|
description: "-",
|
|
|
|
|
fileName: "-",
|
|
|
|
|
fileSize: "-",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const downloadObjectName = computed(() => detailRaw.value?.storagePath || "");
|
|
|
|
|
const canDownload = computed(() => !!downloadObjectName.value);
|
|
|
|
|
|
|
|
|
|
async function handleDownload() {
|
|
|
|
|
const key = downloadObjectName.value.trim();
|
|
|
|
|
if (!key) return;
|
|
|
|
|
|
|
|
|
|
const res = await AttachmentsService.downloadFile(key);
|
|
|
|
|
|
|
|
|
|
const ct = String(res.headers["content-type"] || "").toLowerCase();
|
|
|
|
|
if (ct.includes("application/json")) {
|
|
|
|
|
const text = await (res.data as Blob).text();
|
|
|
|
|
try {
|
|
|
|
|
const json = JSON.parse(text);
|
|
|
|
|
throw new Error(json.message || text);
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error(text);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cd = res.headers["content-disposition"] || "";
|
|
|
|
|
let filename: string | undefined;
|
|
|
|
|
const mUtf8 = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
|
|
|
|
const mStd = cd.match(/filename\s*=\s*(?:"([^"]+)"|([^;]+))/i);
|
|
|
|
|
if (mUtf8?.[1]) {
|
|
|
|
|
try {
|
|
|
|
|
filename = decodeURIComponent(mUtf8[1].trim());
|
|
|
|
|
} catch {
|
|
|
|
|
filename = mUtf8[1].trim();
|
|
|
|
|
}
|
|
|
|
|
} else if (mStd) {
|
|
|
|
|
filename = (mStd[1] || mStd[2])?.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2) 헤더가 없거나 못 읽으면: objectName의 basename으로 강제(fallback)
|
|
|
|
|
if (!filename) {
|
|
|
|
|
// key 예: "11fc4121-...-mlflow_pipeline.yaml" 또는 "dir/subdir/11fc...yaml"
|
|
|
|
|
const parts = key.split(/[\\/]/);
|
|
|
|
|
filename = parts[parts.length - 1] || "download.bin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([res.data]);
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.setAttribute("download", filename);
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
a.remove();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------- utils --------
|
|
|
|
|
const formatIso = (s?: string) =>
|
|
|
|
|
s ? String(s).replace("T", " ").slice(0, 19) : "-";
|
|
|
|
|
|
|
|
|
|
const formatBytes = (b?: number) => {
|
|
|
|
|
const n = Number(b);
|
|
|
|
|
if (!Number.isFinite(n) || n < 0) return "-";
|
|
|
|
|
if (n < 1024) return `${n} B`;
|
|
|
|
|
const u = ["KB", "MB", "GB", "TB"];
|
|
|
|
|
let i = -1,
|
|
|
|
|
v = n;
|
|
|
|
|
do {
|
|
|
|
|
v /= 1024;
|
|
|
|
|
i++;
|
|
|
|
|
} while (v >= 1024 && i < u.length - 1);
|
|
|
|
|
return `${v.toFixed(1)} ${u[i]}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 백엔드 응답 → 화면 모델
|
|
|
|
|
const mapToViewModel = (raw: any) => {
|
|
|
|
|
const projectName =
|
|
|
|
|
raw?.projectName ?? raw?.project?.name ?? raw?.prjNm ?? "-";
|
|
|
|
|
|
|
|
|
|
const size = raw?.fileSize ?? raw?.size ?? raw?.length ?? undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
datasetTitle: raw?.title ?? "-",
|
|
|
|
|
projectName,
|
|
|
|
|
version: raw?.version ?? "-",
|
|
|
|
|
createdDate: formatIso(raw?.regDt),
|
|
|
|
|
createdId: raw?.regUserId ?? "-",
|
|
|
|
|
modifiedDate: formatIso(raw?.modDt),
|
|
|
|
|
modifiedId: raw?.modUserId ?? "-",
|
|
|
|
|
description: raw?.description ?? "-",
|
|
|
|
|
fileName: raw?.originalName ?? "-",
|
|
|
|
|
fileSize: formatBytes(size),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function fetchProjectName(projectId?: number) {
|
|
|
|
|
if (!projectId && projectId !== 0) return;
|
|
|
|
|
try {
|
|
|
|
|
const res = await ProjectService.fetchProjectById(projectId as number);
|
|
|
|
|
const prj = res?.data ?? res;
|
|
|
|
|
experimentInfo.value.projectName = prj?.prjNm ?? prj?.name ?? "-";
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("[Experiment/View] project fetch fail:", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const info = computed(() => mapToViewModel(detailRaw.value || {}));
|
|
|
|
|
|
|
|
|
|
async function fetchDetail(id: number | string) {
|
|
|
|
|
const idNum = typeof id === "string" ? Number(id) : id;
|
|
|
|
|
if (!Number.isFinite(idNum as number)) {
|
|
|
|
|
console.warn("[Datasets/View] invalid id:", id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const res = await AttachmentsService.view(idNum as number);
|
|
|
|
|
detailRaw.value = res?.data ?? res;
|
|
|
|
|
|
|
|
|
|
experimentInfo.value = mapToViewModel(detailRaw.value);
|
|
|
|
|
await fetchProjectName(detailRaw.value?.projectId);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[Datasets/View] fetch detail error:", e);
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------- lifecycle --------
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
fetchDetail(props.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.id,
|
|
|
|
|
(nv) => {
|
|
|
|
|
if (nv !== undefined && nv !== null && nv !== "") fetchDetail(nv);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<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 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">View Details</div>
|
|
|
|
|
</div>
|
|
|
|
|
</v-card-item>
|
|
|
|
|
</v-card>
|
|
|
|
|
|
|
|
|
|
<v-card flat class="bordered-box mb-6 w-100 rounded-lg pa-8">
|
|
|
|
|
<v-card-text class="px-6 pb-6 pt-4">
|
|
|
|
|
<v-row align="center" class="py-2">
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold"
|
|
|
|
|
>Dataset Title
|
|
|
|
|
</v-col>
|
|
|
|
|
<v-col cols="3" class="pa-2">{{
|
|
|
|
|
experimentInfo.datasetTitle
|
|
|
|
|
}}</v-col>
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold">Version </v-col>
|
|
|
|
|
<v-col cols="3" class="pa-2">{{ experimentInfo.version }}</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
<VDivider class="my-2" />
|
|
|
|
|
<!-- Experiment Name -->
|
|
|
|
|
<v-row align="center" class="py-2">
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold"
|
|
|
|
|
>Project Name
|
|
|
|
|
</v-col>
|
|
|
|
|
<v-col cols="9" class="pa-2">{{
|
|
|
|
|
experimentInfo.projectName
|
|
|
|
|
}}</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
<VDivider class="my-2" />
|
|
|
|
|
<v-row align="center" class="py-2">
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold"
|
|
|
|
|
>Created Date
|
|
|
|
|
</v-col>
|
|
|
|
|
<v-col cols="3" class="pa-2">{{
|
|
|
|
|
experimentInfo.createdDate
|
|
|
|
|
}}</v-col>
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold">Created ID </v-col>
|
|
|
|
|
<v-col cols="3" class="pa-2">{{ experimentInfo.createdId }}</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<VDivider class="my-2" />
|
|
|
|
|
|
|
|
|
|
<!-- Description -->
|
|
|
|
|
<v-row align="center" class="py-2">
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold">Description</v-col>
|
|
|
|
|
<v-col cols="9" class="pa-2">{{
|
|
|
|
|
experimentInfo.description
|
|
|
|
|
}}</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
<VDivider class="my-2" />
|
|
|
|
|
<v-row align="center" class="py-2">
|
|
|
|
|
<v-col cols="3" class="text-h6 font-weight-bold">File</v-col>
|
|
|
|
|
<v-col cols="9" class="pa-2 d-flex align-center">
|
|
|
|
|
<span class="text-truncate">{{ experimentInfo.fileName }}</span>
|
|
|
|
|
<v-btn
|
|
|
|
|
icon
|
|
|
|
|
variant="text"
|
|
|
|
|
size="small"
|
|
|
|
|
class="ml-2"
|
|
|
|
|
:disabled="!canDownload"
|
|
|
|
|
@click="handleDownload"
|
|
|
|
|
aria-label="download"
|
|
|
|
|
>
|
|
|
|
|
<v-icon>mdi-download</v-icon>
|
|
|
|
|
</v-btn>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
<v-sheet class="d-flex justify-end mb-2">
|
|
|
|
|
<v-btn color="primary" @click="emit('close')"> Back to List </v-btn>
|
|
|
|
|
</v-sheet>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-container>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.v-card-text {
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
/* 전체 테이블 1px 테두리 */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.v-card-text th {
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
min-width: 400px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.05);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-align: center;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.v-card-text td {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
min-width: 600px;
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
text-align: left;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
|
|
}
|
|
|
|
|
.v-card-text tr:nth-child(odd) {
|
|
|
|
|
background-color: rgba(255, 255, 255, 0.02);
|
|
|
|
|
}
|
|
|
|
|
</style>
|