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.
autoflow-web-console/src/components/templates/Datasets/ViewComponent.vue

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>