You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
8.9 KiB
296 lines
8.9 KiB
<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: "-",
|
|
});
|
|
|
|
// storagePath가 경로(슬래시 포함)이면 우선 사용, 아니면 storedName 사용
|
|
const downloadObjectName = computed(() => {
|
|
const sp = detailRaw.value?.storagePath as string | undefined;
|
|
const sn = detailRaw.value?.storedName as string | undefined;
|
|
const raw = sp && /[\\/]/.test(sp) ? sp : sn || sp || "";
|
|
return (raw || "").replace(/^\/+/, "").replace(/\/{2,}/g, "/");
|
|
});
|
|
|
|
async function handleDownload() {
|
|
const key = downloadObjectName.value;
|
|
if (!key) return;
|
|
|
|
try {
|
|
// 1차: 정규화된 키로 시도 (경로형/파일명형 모두 커버)
|
|
const res = await AttachmentsService.downloadFile(key);
|
|
|
|
// 서버가 에러(JSON) 보낸 경우 감지
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 파일명 추출(헤더 없으면 basename으로)
|
|
const cd = res.headers["content-disposition"] || "";
|
|
const mUtf8 = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
|
const mStd = cd.match(/filename\s*=\s*(?:"([^"]+)"|([^;]+))/i);
|
|
let filename =
|
|
(mUtf8 && decodeURIComponent(mUtf8[1].trim())) ||
|
|
(mStd && (mStd[1] || mStd[2])?.trim()) ||
|
|
key.split(/[\\/]/).pop() ||
|
|
"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);
|
|
} catch (err) {
|
|
// 2차 폴백: 혹시나 경로형이 안 맞으면 basename으로 재시도
|
|
const base = key.split(/[\\/]/).pop() || key;
|
|
if (base && base !== key) {
|
|
const res2 = await AttachmentsService.downloadFile(base);
|
|
const blob = new Blob([res2.data]);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.setAttribute("download", base);
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const canDownload = computed(() => !!downloadObjectName.value);
|
|
|
|
// -------- 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>View Details</div>
|
|
</div>
|
|
</v-card-item>
|
|
</v-card>
|
|
|
|
<v-card
|
|
flat
|
|
class="bordered-box mb-6 w-100 rounded-lg pa-8"
|
|
color="grey-lighten-3"
|
|
>
|
|
<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>
|
|
<div class="d-flex justify-end mb-2">
|
|
<v-btn color="primary" @click="emit('close')"> Back to List </v-btn>
|
|
</div>
|
|
</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>
|