first commit

This commit is contained in:
Chris
2026-03-23 20:23:58 +08:00
commit 74d612aca1
3193 changed files with 692056 additions and 0 deletions

View 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) : "目前尚未記錄更新時間";

View 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,
};

View 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,
};
}

View 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>