first commit
This commit is contained in:
52
frontend/src/module/release/model/release-view-model.js
Normal file
52
frontend/src/module/release/model/release-view-model.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { formatDateTime, statusTypeMap } from "@/module/experiment/model/experiment-view-model";
|
||||
|
||||
export const mapReleaseDetail = (item = {}) => ({
|
||||
id: item.id || "",
|
||||
experimentId: item.experiment_id || "",
|
||||
versionNo: item.version_no ?? 0,
|
||||
status: item.status || "draft",
|
||||
runtimePayload: item.runtime_payload || {},
|
||||
});
|
||||
|
||||
export const buildReleaseStatusTag = (status) => statusTypeMap[status] || "info";
|
||||
|
||||
export const buildReleaseTitle = (release) =>
|
||||
release?.versionNo ? `版本 v${release.versionNo}` : "未命名版本";
|
||||
|
||||
export const buildRuntimePayloadSummary = (payload = {}) => {
|
||||
const topLevelKeys = Object.keys(payload || {});
|
||||
const variants = Array.isArray(payload?.variants) ? payload.variants.length : 0;
|
||||
|
||||
return {
|
||||
keyCount: topLevelKeys.length,
|
||||
variants,
|
||||
keys: topLevelKeys,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildReleaseReadiness = ({ release, payloadSummary }) => {
|
||||
if (!release?.id) {
|
||||
return {
|
||||
level: "warning",
|
||||
title: "版本尚未就緒",
|
||||
description: "先確認版本已建立,再進行執行內容與發佈檢查。",
|
||||
};
|
||||
}
|
||||
|
||||
if (payloadSummary.keyCount === 0) {
|
||||
return {
|
||||
level: "warning",
|
||||
title: "這個版本尚未包含執行內容",
|
||||
description: "先確認版本是否已正確建立執行內容,之後才能往發佈與套用驗證走。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level: "ready",
|
||||
title: "這個版本已具備執行內容",
|
||||
description: "可以發佈此版本,讓頁面變更上線並開始追蹤成效。",
|
||||
};
|
||||
};
|
||||
|
||||
export const buildReleaseUpdatedHint = (value) =>
|
||||
value ? formatDateTime(value) : "目前尚未記錄更新時間";
|
||||
47
frontend/src/module/release/service/release-api.js
Normal file
47
frontend/src/module/release/service/release-api.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import fastapiClient from "@/services/api/fastapi-client";
|
||||
|
||||
const listReleases = async ({ experimentId } = {}) => {
|
||||
const query = experimentId
|
||||
? `?experiment_id=${encodeURIComponent(experimentId)}`
|
||||
: "";
|
||||
const response = await fastapiClient.request(`/api/admin/releases${query}`);
|
||||
return response.items || [];
|
||||
};
|
||||
|
||||
const getRelease = async (releaseId) => {
|
||||
return await fastapiClient.request(`/api/admin/releases/${releaseId}`);
|
||||
};
|
||||
|
||||
const buildRelease = async (experimentId) => {
|
||||
return await fastapiClient.request("/api/admin/releases/build", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ experiment_id: experimentId }),
|
||||
});
|
||||
};
|
||||
|
||||
const publishRelease = async (releaseId) => {
|
||||
return await fastapiClient.request(`/api/admin/releases/${releaseId}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
const rollbackRelease = async (releaseId) => {
|
||||
return await fastapiClient.request(`/api/admin/releases/${releaseId}/rollback`, {
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
const archiveRelease = async (releaseId) => {
|
||||
return await fastapiClient.request(`/api/admin/releases/${releaseId}/archive`, {
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
listReleases,
|
||||
getRelease,
|
||||
buildRelease,
|
||||
publishRelease,
|
||||
rollbackRelease,
|
||||
archiveRelease,
|
||||
};
|
||||
160
frontend/src/module/release/service/use-release-detail-page.js
Normal file
160
frontend/src/module/release/service/use-release-detail-page.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import experimentApi from "@/module/experiment/service/experiment-api";
|
||||
import variantApi from "@/module/variant/service/variant-api";
|
||||
import {
|
||||
mapExperimentDetail,
|
||||
resolveExperimentBaseUrl,
|
||||
statusTypeMap,
|
||||
} from "@/module/experiment/model/experiment-view-model";
|
||||
import releaseApi from "@/module/release/service/release-api";
|
||||
import {
|
||||
buildReleaseReadiness,
|
||||
buildReleaseStatusTag,
|
||||
buildReleaseTitle,
|
||||
buildRuntimePayloadSummary,
|
||||
mapReleaseDetail,
|
||||
} from "@/module/release/model/release-view-model";
|
||||
|
||||
export function useReleaseDetailPage() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const release = ref(null);
|
||||
const experiment = ref(null);
|
||||
const variants = ref([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const releaseId = computed(() => String(route.params.releaseId || ""));
|
||||
const payloadSummary = computed(() =>
|
||||
buildRuntimePayloadSummary(release.value?.runtimePayload || {})
|
||||
);
|
||||
const releaseReadiness = computed(() =>
|
||||
buildReleaseReadiness({
|
||||
release: release.value,
|
||||
payloadSummary: payloadSummary.value,
|
||||
})
|
||||
);
|
||||
const releaseTitle = computed(() => buildReleaseTitle(release.value));
|
||||
const releaseStatusTag = computed(() =>
|
||||
buildReleaseStatusTag(release.value?.status)
|
||||
);
|
||||
const runtimePayloadPreview = computed(() =>
|
||||
JSON.stringify(release.value?.runtimePayload || {}, null, 2)
|
||||
);
|
||||
const recommendedBaseUrl = computed(() => resolveExperimentBaseUrl(experiment.value));
|
||||
const primaryVariant = computed(() => variants.value[0] || null);
|
||||
const nextStepSummary = computed(() => {
|
||||
if (payloadSummary.value.keyCount === 0) {
|
||||
return {
|
||||
title: "先回到主要變體補內容",
|
||||
description:
|
||||
"這個版本還沒有帶出 runtime payload,建議先回到主要變體編輯器,補上內容後再重新檢查。",
|
||||
actionLabel: "回到主要變體編輯器",
|
||||
actionType: "editor",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "回畫面確認最新版本效果",
|
||||
description:
|
||||
"這個版本已有 payload,可先回主要變體編輯器,對照畫面與版本內容是否一致。",
|
||||
actionLabel: "前往主要變體編輯器",
|
||||
actionType: "editor",
|
||||
};
|
||||
});
|
||||
|
||||
const loadPage = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const releaseItem = await releaseApi.getRelease(releaseId.value);
|
||||
const mappedRelease = mapReleaseDetail(releaseItem);
|
||||
release.value = mappedRelease;
|
||||
const [experimentItem, variantItems] = await Promise.all([
|
||||
experimentApi.getExperiment(mappedRelease.experimentId),
|
||||
variantApi.listVariants({ experimentId: mappedRelease.experimentId }),
|
||||
]);
|
||||
|
||||
experiment.value = mapExperimentDetail(experimentItem);
|
||||
variants.value = variantItems;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || "無法載入 release detail。";
|
||||
release.value = null;
|
||||
experiment.value = null;
|
||||
variants.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openExperiment = () => {
|
||||
if (!release.value?.experimentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: "experiment-detail",
|
||||
params: { experimentId: release.value.experimentId },
|
||||
});
|
||||
};
|
||||
|
||||
const openPrimaryVariantEditor = () => {
|
||||
if (!primaryVariant.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: "editor-variant",
|
||||
params: { variantId: primaryVariant.value.id },
|
||||
query: recommendedBaseUrl.value
|
||||
? {
|
||||
base_url: recommendedBaseUrl.value,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (release.value?.experimentId) {
|
||||
openExperiment();
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ name: "experiments" });
|
||||
};
|
||||
|
||||
const runNextStep = () => {
|
||||
openPrimaryVariantEditor();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPage();
|
||||
});
|
||||
|
||||
return {
|
||||
route,
|
||||
loading,
|
||||
errorMessage,
|
||||
release,
|
||||
experiment,
|
||||
variants,
|
||||
payloadSummary,
|
||||
releaseReadiness,
|
||||
releaseTitle,
|
||||
releaseStatusTag,
|
||||
runtimePayloadPreview,
|
||||
recommendedBaseUrl,
|
||||
primaryVariant,
|
||||
nextStepSummary,
|
||||
statusTypeMap,
|
||||
loadPage,
|
||||
openExperiment,
|
||||
openPrimaryVariantEditor,
|
||||
goBack,
|
||||
runNextStep,
|
||||
};
|
||||
}
|
||||
288
frontend/src/module/release/view/detail.vue
Normal file
288
frontend/src/module/release/view/detail.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<AdminPageShell
|
||||
eyebrow="版本詳情"
|
||||
:title="releaseTitle"
|
||||
:description="release?.id || route.params.releaseId"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button @click="goBack">返回實驗</el-button>
|
||||
<el-button @click="loadPage">重新整理</el-button>
|
||||
<el-button
|
||||
v-if="release?.status !== 'published'"
|
||||
type="success"
|
||||
:loading="lifecycleLoading"
|
||||
@click="handlePublish"
|
||||
>
|
||||
發佈版本
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="release?.status === 'published'"
|
||||
type="warning"
|
||||
:loading="lifecycleLoading"
|
||||
@click="handleRollback"
|
||||
>
|
||||
回退至草稿
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!primaryVariant?.id" @click="openPrimaryVariantEditor">
|
||||
回到主要變體編輯器
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<template #summary>
|
||||
<div class="metrics-grid">
|
||||
<AdminMetricCard label="狀態" :value="release?.status || '-'" :tone="releaseStatusTag" hint="目前 release 狀態" />
|
||||
<AdminMetricCard label="版本號" :value="release?.versionNo ?? 0" tone="info" hint="目前 release 版本號" />
|
||||
<AdminMetricCard label="執行內容欄位" :value="payloadSummary.keyCount" hint="版本執行內容的第一層欄位數量" />
|
||||
<AdminMetricCard label="變體數量" :value="payloadSummary.variants" tone="success" hint="已收進版本快照的變體數量" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Left: summary + payload -->
|
||||
<div class="left-col">
|
||||
<div class="card">
|
||||
<div class="card__header">
|
||||
<p class="card__title">版本摘要</p>
|
||||
<el-tag :type="releaseStatusTag" effect="light">{{ release?.status || "-" }}</el-tag>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="所屬實驗">
|
||||
<el-button link type="primary" :disabled="!release?.experimentId" @click="openExperiment">
|
||||
{{ experiment?.name || release?.experimentId || "-" }}
|
||||
</el-button>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="頁面網址">
|
||||
<span class="break-all">{{ recommendedBaseUrl || "尚未設定" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="第一層欄位">
|
||||
{{ payloadSummary.keys.join(", ") || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="執行內容">
|
||||
{{ payloadSummary.keyCount > 0 ? "已建立" : "尚未建立" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card__title" style="margin-bottom: 14px">版本執行內容</p>
|
||||
<pre class="code-block"><code>{{ runtimePayloadPreview }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: readiness + next step -->
|
||||
<div class="side-col">
|
||||
<div class="card">
|
||||
<p class="card__title" style="margin-bottom: 14px">版本可用狀態</p>
|
||||
<div
|
||||
class="status-block"
|
||||
:class="releaseReadiness.level === 'ready' ? 'status-block--success' : 'status-block--warning'"
|
||||
>
|
||||
<p class="status-block__heading">{{ releaseReadiness.title }}</p>
|
||||
<p class="status-block__desc">{{ releaseReadiness.description }}</p>
|
||||
<div class="status-block__actions">
|
||||
<el-button size="small" :disabled="!primaryVariant?.id" @click="openPrimaryVariantEditor">
|
||||
回到主要變體編輯器
|
||||
</el-button>
|
||||
<el-button size="small" @click="openExperiment">回到實驗設定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card__title" style="margin-bottom: 14px">現在建議做的事</p>
|
||||
<div class="status-block status-block--info">
|
||||
<p class="status-block__heading">{{ nextStepSummary.title }}</p>
|
||||
<p class="status-block__desc">{{ nextStepSummary.description }}</p>
|
||||
<el-button
|
||||
class="status-block__btn"
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!primaryVariant?.id"
|
||||
@click="runNextStep"
|
||||
>
|
||||
{{ nextStepSummary.actionLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue"
|
||||
import { ElMessage } from "element-plus"
|
||||
import AdminMetricCard from "@/components/AdminMetricCard.vue";
|
||||
import AdminPageShell from "@/components/AdminPageShell.vue";
|
||||
import { useReleaseDetailPage } from "@/module/release/service/use-release-detail-page";
|
||||
import releaseApi from "@/module/release/service/release-api";
|
||||
|
||||
const {
|
||||
route,
|
||||
errorMessage,
|
||||
release,
|
||||
payloadSummary,
|
||||
releaseReadiness,
|
||||
releaseTitle,
|
||||
releaseStatusTag,
|
||||
runtimePayloadPreview,
|
||||
recommendedBaseUrl,
|
||||
experiment,
|
||||
primaryVariant,
|
||||
nextStepSummary,
|
||||
loadPage,
|
||||
openExperiment,
|
||||
openPrimaryVariantEditor,
|
||||
goBack,
|
||||
runNextStep,
|
||||
} = useReleaseDetailPage();
|
||||
|
||||
const lifecycleLoading = ref(false)
|
||||
|
||||
const handlePublish = async () => {
|
||||
lifecycleLoading.value = true
|
||||
try {
|
||||
await releaseApi.publishRelease(release.value.id)
|
||||
ElMessage.success("Release 已發佈")
|
||||
await loadPage()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "發佈失敗")
|
||||
} finally {
|
||||
lifecycleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async () => {
|
||||
lifecycleLoading.value = true
|
||||
try {
|
||||
await releaseApi.rollbackRelease(release.value.id)
|
||||
ElMessage.success("Release 已回退至草稿")
|
||||
await loadPage()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "回退失敗")
|
||||
} finally {
|
||||
lifecycleLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.left-col,
|
||||
.side-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.code-block {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Status blocks */
|
||||
.status-block {
|
||||
border: 1px solid;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-block--success {
|
||||
border-color: #d1fae5;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.status-block--warning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.status-block--info {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.status-block__heading {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.status-block__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.status-block__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-block__btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user