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

BIN
frontend/src/module/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,538 @@
<template>
<el-container v-if="timer_show" class="ose-container tw-h-full tw-w-full tw-justify-center tw-items-center">
<img v-if="overlay_img_show" style=" width: 100%; height: 100%;" :src="params.overlay_img"/>
<article class="tw-h-full tw-w-full tw-flex tw-flex-col tw-justify-center tw-items-center"
:style="[
`background-color: ${params.bg_color}`,
!params.bg_color2?'':`background-image: linear-gradient(${params.bg_color_gradient_direction}deg, ${params.bg_color} ${params.bg_color_gradient_position}%, ${params.bg_color2} ${params.bg_color2_gradient_position}%);`,
`background-image: url('${params.bg_img_url}') ; background-repeat : no-repeat ; background-size : 100% 100%`
]"
>
<el-row class="tw-relative">
<!-- Header-->
<el-col v-if="params.header_switch=='Y'?true:false" class="tw-text-center">
<span id="ose-cdt-header"
class="tw-relative ose-text"
:style="[
`top: ${params.header_y_position}px`,
`font-size: ${params.header_font_size}px`,
`color: ${params.header_color}`,
params.header_color2?
`background-image: linear-gradient(${params.header_color_gradient_direction}deg, ${params.header_color} ${params.header_color_gradient_position}%, ${params.header_color2} ${params.header_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.header_color_gradient_direction}deg, ${params.header_color} ${params.header_color_gradient_position}%, ${params.header_color} ${params.header_color2_gradient_position}%);`
]"
v-html="header_text"
>
</span>
</el-col>
<!-- Timer-->
<el-col class="tw-flex tw-justify-center tw-text-center">
<el-row :gutter="params.timer_x_space">
<!-- -->
<el-col span="3.4">
<p class="tw-relative ose-text"
:style="[
`top: ${params.time_y_position}px`,
`font-size: ${params.time_font_size}px`,
`color: ${params.time_color}`,
params.time_color2?
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color2} ${params.time_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color} ${params.time_color2_gradient_position}%);`
]"
>
{{ t_day }}
</p>
<p class="tw-relative ose-text"
:style="[
`top: ${params.unit_y_position}px`,
`font-size: ${params.unit_font_size}px`,
`color: ${params.unit_color}`,
params.unit_color2?
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color2} ${params.unit_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color} ${params.unit_color2_gradient_position}%);`
]"
>
</p>
</el-col>
<el-col span="3.4" class="">
<span class="tw-relative ose-text-symbol"
:style="[
`top: ${params.symbol_y_position}px`,
`font-size: ${params.symbol_font_size}px`,
`color: ${params.symbol_color}`,
params.symbol_color2?
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color2} ${params.symbol_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color} ${params.symbol_color2_gradient_position}%);`
]"
>
:
</span>
</el-col>
<!-- -->
<el-col span="3.4">
<p class="tw-relative ose-text"
:style="[
`top: ${params.time_y_position}px`,
`font-size: ${params.time_font_size}px`,
`color: ${params.time_color}`,
params.time_color2?
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color2} ${params.time_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color} ${params.time_color2_gradient_position}%);`
]"
>
{{ t_hour }}
</p>
<p class="tw-relative ose-text"
:style="[
`top: ${params.unit_y_position}px`,
`font-size: ${params.unit_font_size}px`,
`color: ${params.unit_color}`,
params.unit_color2?
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color2} ${params.unit_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color} ${params.unit_color2_gradient_position}%);`
]"
>
</p>
</el-col>
<el-col span="3.4" class="">
<span class="tw-relative ose-text-symbol"
:style="[
`top: ${params.symbol_y_position}px`,
`font-size: ${params.symbol_font_size}px`,
`color: ${params.symbol_color}`,
params.symbol_color2?
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color2} ${params.symbol_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color} ${params.symbol_color2_gradient_position}%);`
]"
>
:
</span>
</el-col>
<!-- -->
<el-col span="3.4">
<p class="tw-relative ose-text"
:style="[
`top: ${params.time_y_position}px`,
`font-size: ${params.time_font_size}px`,
`color: ${params.time_color}`,
params.time_color2?
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color2} ${params.time_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color} ${params.time_color2_gradient_position}%);`
]"
>
{{ t_minute }}
</p>
<p class="tw-relative ose-text"
:style="[
`top: ${params.unit_y_position}px`,
`font-size: ${params.unit_font_size}px`,
`color: ${params.unit_color}`,
params.unit_color2?
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color2} ${params.unit_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color} ${params.unit_color2_gradient_position}%);`
]"
>
</p>
</el-col>
<el-col span="3.4" class="">
<span class="tw-relative ose-text-symbol"
:style="[
`top: ${params.symbol_y_position}px`,
`font-size: ${params.symbol_font_size}px`,
`color: ${params.symbol_color}`,
params.symbol_color2?
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color2} ${params.symbol_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.symbol_color_gradient_direction}deg, ${params.symbol_color} ${params.symbol_color_gradient_position}%, ${params.symbol_color} ${params.symbol_color2_gradient_position}%);`
]"
>
:
</span>
</el-col>
<!-- -->
<el-col span="3.4">
<p class="tw-relative ose-text"
:style="[
`top: ${params.time_y_position}px`,
`font-size: ${params.time_font_size}px`,
`color: ${params.time_color}`,
params.time_color2?
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color2} ${params.time_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.time_color_gradient_direction}deg, ${params.time_color} ${params.time_color_gradient_position}%, ${params.time_color} ${params.time_color2_gradient_position}%);`
]"
>
{{ t_second }}
</p>
<p class="tw-relative ose-text"
:style="[
`top: ${params.unit_y_position}px`,
`font-size: ${params.unit_font_size}px`,
`color: ${params.unit_color}`,
params.unit_color2?
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color2} ${params.unit_color2_gradient_position}%);`:
`background-image: linear-gradient(${params.unit_color_gradient_direction}deg, ${params.unit_color} ${params.unit_color_gradient_position}%, ${params.unit_color} ${params.unit_color2_gradient_position}%);`
]"
>
</p>
</el-col>
</el-row>
</el-col>
</el-row>
</article>
</el-container>
</template>
<script setup>
import {computed, reactive, ref, toRefs, watch} from "vue";
import {useRoute} from 'vue-router';
const route = useRoute();
const query_params = computed(() => route.query.params)
const props = defineProps({
dev: {
default: false
},
modelValue: {
default: ""
},
timer_type: {
default: 1
},
start_at: {
default: null
},
end_at: {
default: null
},
repeat_day: {
default: 0
},
bg_color: {
default: "#FFFFFFFF"
},
bg_color2: {
default: null
},
bg_color_gradient_direction: {
default: 62
},
bg_color_gradient_position: {
default: 0
},
bg_color2_gradient_position: {
default: 100
},
bg_img_url:{
default:""
},
header_switch: {
default: "Y"
},
header_text_prepare: {
default: "優 惠 活 動 尚 未 開 始"
},
header_text_ing: {
default: "優 惠 倒 數 計 時 中"
},
header_text_finish: {
default: "優 惠 活 動 已 結 束"
},
header_y_position: {
default: 0
},
header_font_size: {
default: 20
},
header_color: {
default: "black"
},
header_color2: {
default: null
},
header_color_gradient_direction: {
default: 62
},
header_color_gradient_position: {
default: 0
},
header_color2_gradient_position: {
default: 100
},
timer_x_space: {
default: 10
},
time_y_position: {
default: 0
},
time_font_size: {
default: 20
},
time_color: {
default: "black"
},
time_color2: {
default: null
},
time_color_gradient_direction: {
default: 62
},
time_color_gradient_position: {
default: 0
},
time_color2_gradient_position: {
default: 100
},
symbol_y_position: {
default: ""
},
symbol_font_size: {
default: 20
},
symbol_color: {
default: "black"
},
symbol_color2: {
default: null
},
symbol_color_gradient_direction: {
default: 62
},
symbol_color_gradient_position: {
default: 0
},
symbol_color2_gradient_position: {
default: 100
},
unit_y_position: {
default: ""
},
unit_font_size: {
default: 20
},
unit_color: {
default: "black"
},
unit_color2: {
default: null
},
unit_color_gradient_direction: {
default: 62
},
unit_color_gradient_position: {
default: 0
},
unit_color2_gradient_position: {
default: 100
},
overlay_img: {
default: null
},
})
let params = reactive(props.dev ? props.modelValue : {})
const header_text = ref('優 惠 倒 數 計 時 中')
const t_day = ref('00')
const t_hour = ref('00')
const t_minute = ref('00')
const t_second = ref('00')
const timer_id = ref()
const timer_show = ref(true)
const overlay_img_show = ref(false)
const getCountDownUnit = (distance) => {
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
t_day.value = days.toString()
if (days < 10) {
t_day.value = '0' + t_day.value
}
t_hour.value = hours.toString()
if (hours < 10) {
t_hour.value = '0' + t_hour.value
}
t_minute.value = minutes.toString()
if (minutes < 10) {
t_minute.value = '0' + t_minute.value
}
t_second.value = seconds.toString()
if (seconds < 10) {
t_second.value = '0' + t_second.value
}
}
const initTimerHeader = (timer_type) => {
const EMOJI_REGEX = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g;
if (timer_type !== 'on' && params.overlay_img) {
timer_show.value = false
overlay_img_show.value = true
return;
}
if (timer_type !== 'on' && (!params.overlay_img || params.overlay_img == '')) {
timer_show.value = false
overlay_img_show.value = false
return;
}
timer_show.value = true
switch (timer_type) {
case 'prepare' :
header_text.value = params.header_text_prepare.replace(EMOJI_REGEX,`<span class="emoji">$&</span>`)
return
case 'on' :
header_text.value = params.header_text_ing.replace(EMOJI_REGEX,`<span class="emoji">$&</span>`)
return
case 'end' :
header_text.value = params.header_text_finish.replace(EMOJI_REGEX,`<span class="emoji">$&</span>`)
return
}
}
const initTimer = (start_at, end_at, repeat_day) => {
let timer_type = 'prepare';
let init_now = new Date().getTime();
let init_distance = end_at - init_now;
// 重複模式
if (repeat_day && end_at < init_now) {
let repeat_day_second = repeat_day * 24 * 60 * 60 * 1000
let lost_duration = 0
if (new Date().getTime() - end_at < repeat_day_second) {
lost_duration = repeat_day_second - (new Date().getTime() - end_at)
} else {
lost_duration = repeat_day_second - (new Date().getTime() - end_at) % repeat_day_second
}
init_distance = lost_duration
}
if (init_distance < 0 || isNaN(init_distance)) {
timer_type = 'end'
return timer_type
}
if (init_now < start_at) {
init_distance = end_at - start_at;
return timer_type
}
timer_type = 'on'
timer_id.value = setInterval(function () {
let now = new Date().getTime();
let distance = end_at - now;
if (repeat_day && end_at < now) {
let repeat_day_second = repeat_day * 24 * 60 * 60 * 1000
let lost_duration = 0
if (new Date().getTime() - end_at < repeat_day_second) {
lost_duration = repeat_day_second - (new Date().getTime() - end_at)
} else {
lost_duration = repeat_day_second - (new Date().getTime() - end_at) % repeat_day_second
}
distance = lost_duration
// distance = repeat_day_second - lost_duration
}
if (distance < 0 || isNaN(distance)) {
clearInterval(timer_id.value);
t_day.value = '00'
t_hour.value = '00'
t_minute.value = '00'
t_second.value = '00'
return
}
let show_units = getCountDownUnit(distance)
}, 1000);
return timer_type
}
const main = () => {
if (!params.start_at || !params.end_at) {
return
}
let start_at = new Date(params.start_at)
let end_at = new Date(params.end_at)
start_at = start_at.getTime()
end_at = end_at.getTime()
const timer_type = initTimer(start_at, end_at, params.repeat_day)
initTimerHeader(timer_type)
}
const initPage = async () => {
if (query_params.value && !props.dev) {
const decodedURL = decodeURIComponent(atob(query_params.value));
const q_result = JSON.parse(decodedURL)
for (let key in q_result) {
const val = q_result[key]
params[key] = val
}
}
main()
}
watch(() => [
params.start_at, params.end_at, params.repeat_day,
params.header_text_prepare,params.header_text_ing,params.header_text_finish
], () => {
if (timer_id.value)
clearInterval(timer_id.value);
main()
})
watch(query_params, newSearchQuery => initPage())
initPage()
</script>
<style>
html {
height: 100%;
}
body {
height: 100%;
}
#app {
height: 100%;
}
.ose-container {
font-family: 'Source Sans Pro', 'Open Sans', 'Helvetica Neue', Helvetica, Arial, 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', 'STHeiti', 'WenQuanYi Micro Hei', SimSun, sans-serif;
}
.ose-text {
font-weight: bold;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-color: #000;
}
.ose-text-symbol {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-color: #000;
}
.emoji {
-webkit-text-fill-color: initial;
}
</style>

View File

@@ -0,0 +1,39 @@
export default [
{
id:"id",
name:"序列",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
// width:"100",
}
},
{
id:"name",
name:"倒數計時器名稱",
header_setup:{
sortable:false,
"show-overflow-tooltip":true,
// width:"280"
}
},
{
id:"start_at",
name:"開始時間",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
// width:"180"
}
},
{
id:"end_at",
name:"結束時間",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
// width:"180"
}
}
]

View File

@@ -0,0 +1,98 @@
import {
createItem,
deleteItem,
readFiles,
readFolders,
readItems,
updateFile,
updateItem,
uploadFiles,
} from "@directus/sdk";
import { buildDirectusAssetUrl } from "@/config/env";
export const listCountdownTimers = async (client, corporateCustomerId) => {
return await client.request(
readItems("countdown_timer", {
filter: {
corporate_customer: {
_eq: corporateCustomerId,
},
},
})
);
};
export const deleteCountdownTimerById = async (client, id) => {
return await client.request(deleteItem("countdown_timer", id));
};
export const saveCountdownTimerItem = async (client, options) => {
if (options.mode === "edit") {
return await client.request(
updateItem("countdown_timer", options.id, options.item)
);
}
return await client.request(createItem("countdown_timer", options.item));
};
export const listCountdownTimerImages = async (client) => {
const files = await client.request(
readFiles({
filter: {
type: {
_contains: "image",
},
folder: {
name: {
_eq: "Countdown Timer",
},
},
},
fields: ["id"],
sort: ["-uploaded_on"],
})
);
return files.map((item) => ({
url: buildDirectusAssetUrl(item.id),
}));
};
export const getCountdownTimerFolderId = async (client) => {
const folders = await client.request(
readFolders({
fields: ["id"],
filter: {
name: "Countdown Timer",
},
})
);
return folders[0]?.id || null;
};
export const moveCountdownTimerFileToFolder = async (
client,
fileId,
folderId
) => {
return await client.request(
updateFile(fileId, {
folder: folderId,
})
);
};
export const uploadCountdownTimerImage = async (client, file, folderId) => {
const result = await client.request(uploadFiles(file));
if (folderId) {
await moveCountdownTimerFileToFolder(client, result.id, folderId);
}
return {
id: result.id,
url: buildDirectusAssetUrl(result.id),
};
};

View File

@@ -0,0 +1,69 @@
import {
deleteCountdownTimerById,
getCountdownTimerFolderId,
listCountdownTimerImages,
listCountdownTimers,
moveCountdownTimerFileToFolder,
saveCountdownTimerItem,
uploadCountdownTimerImage,
} from "@/module/conutdown-timer/service/countdown-timer-service";
export default {
namespaced: true,
state: {
countdown_timer_list: [],
edit_countdown_timer_data: JSON.parse(sessionStorage.getItem('edit_countdown_timer_data')) || {},
images: [],
},
actions: {
async getCountdownTimerList({rootState, commit, dispatch}, corporate_customer_id) {
const client = rootState.directus.client
const result = await listCountdownTimers(client, corporate_customer_id);
commit("countdown_timer_list", result)
},
async deleteCountdownTimer({rootState, commit, dispatch}, id) {
const client = rootState.directus.client
await deleteCountdownTimerById(client, id);
await dispatch("getCountdownTimerList", rootState.user.current_corporate_customer)
},
async saveCountdownTimer({rootState, commit, dispatch}, options) {
const client = rootState.directus.client
return await saveCountdownTimerItem(client, options)
},
async getImages({rootState, commit, dispatch}) {
const client = rootState.directus.client
const result = await listCountdownTimerImages(client)
commit("images", result)
},
async getFolder({rootState, commit, dispatch}) {
const client = rootState.directus.client
return await getCountdownTimerFolderId(client)
},
async changeFileFolder({rootState, commit, dispatch}, fileId) {
const client = rootState.directus.client
const folderId = await dispatch('getFolder')
await moveCountdownTimerFileToFolder(client, fileId, folderId)
},
async uploadImages({rootState, commit, dispatch}, file) {
const client = rootState.directus.client
const folderId = await dispatch('getFolder')
const result = await uploadCountdownTimerImage(client, file, folderId)
commit("addNewImage", {url: result.url})
}
},
mutations: {
countdown_timer_list(state, val) {
state.countdown_timer_list = val
},
edit_countdown_timer_data(state, val) {
state.edit_countdown_timer_data = val
},
images(state, val) {
state.images = val
},
addNewImage(state, val) {
state.images.unshift(val)
}
},
getters: {}
}

View File

@@ -0,0 +1,927 @@
<template>
<el-main class="tw-h-full" v-loading="page_loading">
<el-row class="tw-h-full" :gutter="50">
<!-- setting-->
<el-col :span="14" class="tw-h-full">
<el-card shadow="never" class="tw-h-full" :body-style="{height:'89.5%'}">
<template #default>
<el-tabs stretch class="tw-h-full ose-setting-tabs">
<el-tab-pane label="基本設定" class="tw-h-full">
<el-main class="tw-space-y-10 tw-text-base tw-font-light tw-h-full">
<!-- 基本-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">基本</p>
<!-- 倒數計時器名稱-->
<el-row class="tw-items-center ">
<!-- 進行中文字內容-->
<el-col :span="5">
倒數計時器名稱
</el-col>
<el-col :span="19">
<el-input
:input-style="{fontSize:'medium'}"
size="large"
v-model="data.name"
/>
</el-col>
</el-row>
<!-- 開始 結束-->
<el-row class="tw-items-center">
<el-col :span="5">
<span class="">開始</span>
</el-col>
<el-col :span="8">
<el-date-picker
:input-style="{fontSize:'medium'}"
v-model="data.start_at"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
placeholder="請選擇開始時間日期"
size="large"
style="width: 100%"
/>
</el-col>
<el-col :span="3" class="tw-pl-10">
<span class="">結束</span>
</el-col>
<el-col :span="8">
<el-date-picker
:input-style="{fontSize:'medium'}"
v-model="data.end_at"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
placeholder="請選擇開始時間日期"
size="large"
style="width: 100%"
/>
</el-col>
</el-row>
<!-- 圖卡高度-->
<el-row class="tw-items-center">
<el-col :span="5">
圖卡高度
</el-col>
<el-col :span="19">
<el-slider v-model="data.height"
:min="50" :max="500"
/>
</el-col>
</el-row>
<!-- 重複天數-->
<el-row class="tw-items-center">
<el-col :span="5">
重複天數
</el-col>
<el-col :span="19">
<el-slider v-model="data.repeat_day"
:min="0" :max="10"
/>
</el-col>
</el-row>
</section>
<!-- 背景-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">背景</p>
<!-- 上傳圖片
<el-row class="tw-items-center">
<el-col :span="5">
上傳圖片
</el-col>
<el-col :span="6" class="tw-pr-10">
<el-button type="primary" size="default" :color="'#2f91ee'" @click="images_drawer_show =true"
plain>
選擇 / 上傳圖片
</el-button>
</el-col>
</el-row>-->
<!-- 背景色/背景漸層色-->
<el-row class="tw-items-center">
<!-- 背景色-->
<el-col :span="5">
背景色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.bg_color" show-alpha color-format="hex"
@active-change="colorChange('bg_color',$event)"/>
</el-col>
<!-- 背景漸層色-->
<el-col :span="5">
背景漸層色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.bg_color2" show-alpha color-format="hex"
@active-change="colorChange('bg_color2',$event)"/>
</el-col>
</el-row>
<!-- 漸層方向-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層方向
</el-col>
<el-col :span="19">
<el-slider v-model="data.bg_color_gradient_direction"
:min="-200" :max="200"
:disabled="!data.bg_color2?true:false"
/>
</el-col>
</el-row>
<!-- 背景色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
背景色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.bg_color_gradient_position"
:disabled="!data.bg_color2?true:false"
/>
</el-col>
</el-row>
<!-- 漸層色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.bg_color2_gradient_position"
:disabled="!data.bg_color2?true:false"
/>
</el-col>
</el-row>
</section>
<!-- 標題-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">標題</p>
<!-- 顯示標題-->
<el-row class="tw-items-center">
<el-col :span="5">
顯示標題
</el-col>
<el-col :span="2">
<el-switch
v-model="data.header_switch"
inline-prompt
active-text=""
inactive-text=""
active-value="Y"
inactive-value="N"
/>
</el-col>
</el-row>
<!-- 開始前文字內容
<el-row class="tw-items-center ">
&lt;!&ndash; 開始前文字內容&ndash;&gt;
<el-col :span="5">
開始前文字內容
</el-col>
<el-col :span="19">
<el-input
:input-style="{fontSize:'medium'}"
size="large"
v-model="data.header_text_prepare"
/>
</el-col>
</el-row>-->
<!-- 進行中文字內容-->
<el-row class="tw-items-center ">
<!-- 進行中文字內容-->
<el-col :span="5">
文字內容
</el-col>
<el-col :span="19">
<el-input
:input-style="{fontSize:'medium'}"
size="large"
v-model="data.header_text_ing"
/>
</el-col>
</el-row>
<!-- 結束後文字內容
<el-row class="tw-items-center ">
&lt;!&ndash; 結束後文字內容&ndash;&gt;
<el-col :span="5">
結束後文字內容
</el-col>
<el-col :span="19">
<el-input
:input-style="{fontSize:'medium'}"
size="large"
v-model="data.header_text_finish"
/>
</el-col>
</el-row>-->
<!-- 文字顏色/文字漸層色-->
<el-row class="tw-items-center">
<!-- 文字顏色-->
<el-col :span="5">
文字顏色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.header_color" show-alpha color-format="hex"
@active-change="colorChange('header_color',$event)"/>
</el-col>
<!-- 文字漸層色-->
<el-col :span="5">
文字漸層色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.header_color2" show-alpha color-format="hex"
@active-change="colorChange('header_color2',$event)"/>
</el-col>
</el-row>
<!-- 文字位置-->
<el-row class="tw-items-center">
<el-col :span="5">
文字位置
</el-col>
<el-col :span="19">
<el-slider v-model="data.header_y_position"
:min="-200" :max="200"
/>
</el-col>
</el-row>
<!-- 文字大小-->
<el-row class="tw-items-center">
<el-col :span="5">
文字大小
</el-col>
<el-col :span="19">
<el-slider v-model="data.header_font_size"
/>
</el-col>
</el-row>
<!-- 漸層方向-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層方向
</el-col>
<el-col :span="19">
<el-slider v-model="data.header_color_gradient_direction"
:min="-200" :max="200"
:disabled="!data.header_color2?true:false"
/>
</el-col>
</el-row>
<!-- 文字顏色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
文字顏色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.header_color_gradient_position"
:disabled="!data.header_color2?true:false"
/>
</el-col>
</el-row>
<!-- 漸層色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.header_color2_gradient_position"
:disabled="!data.header_color2?true:false"
/>
</el-col>
</el-row>
</section>
</el-main>
</el-tab-pane>
<el-tab-pane label="倒數計時" class="tw-h-full">
<el-main class="tw-space-y-10 tw-text-base tw-font-light tw-h-full">
<section class="tw-space-y-5">
<!-- X間距調整-->
<el-row class="tw-items-center">
<el-col :span="5">
X軸間距調整
</el-col>
<el-col :span="19">
<el-slider v-model="data.timer_x_space"
:min="0" :max="50"
/>
</el-col>
</el-row>
</section>
<!-- 時間-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">時間</p>
<!-- 文字顏色/文字漸層色-->
<el-row class="tw-items-center">
<!-- 文字顏色-->
<el-col :span="5">
文字顏色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.time_color" show-alpha color-format="hex"
@active-change="colorChange('time_color',$event)"/>
</el-col>
<!-- 文字漸層色-->
<el-col :span="5">
文字漸層色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.time_color2" show-alpha color-format="hex"
@active-change="colorChange('time_color2',$event)"/>
</el-col>
</el-row>
<!-- 文字位置-->
<el-row class="tw-items-center">
<el-col :span="5">
文字位置
</el-col>
<el-col :span="19">
<el-slider v-model="data.time_y_position"
:min="-200" :max="200"
/>
</el-col>
</el-row>
<!-- 文字大小-->
<el-row class="tw-items-center">
<el-col :span="5">
文字大小
</el-col>
<el-col :span="19">
<el-slider v-model="data.time_font_size"
/>
</el-col>
</el-row>
<!-- 漸層方向-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層方向
</el-col>
<el-col :span="19">
<el-slider v-model="data.time_color_gradient_direction"
:min="-200" :max="200"
:disabled="!data.time_color2?true:false"
/>
</el-col>
</el-row>
<!-- 文字顏色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
文字顏色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.time_color_gradient_position"
:disabled="!data.time_color2?true:false"
/>
</el-col>
</el-row>
<!-- 漸層色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.time_color2_gradient_position"
:disabled="!data.time_color2?true:false"
/>
</el-col>
</el-row>
</section>
<!-- 單位-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">單位</p>
<!-- 文字顏色/文字漸層色-->
<el-row class="tw-items-center">
<!-- 文字顏色-->
<el-col :span="5">
文字顏色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.unit_color" show-alpha color-format="hex"
@active-change="colorChange('unit_color',$event)"/>
</el-col>
<!-- 文字漸層色-->
<el-col :span="5">
文字漸層色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.unit_color2" show-alpha color-format="hex"
@active-change="colorChange('unit_color2',$event)"/>
</el-col>
</el-row>
<!-- 文字位置-->
<el-row class="tw-items-center">
<el-col :span="5">
文字位置
</el-col>
<el-col :span="19">
<el-slider v-model="data.unit_y_position"
:min="-200" :max="200"
/>
</el-col>
</el-row>
<!-- 文字大小-->
<el-row class="tw-items-center">
<el-col :span="5">
文字大小
</el-col>
<el-col :span="19">
<el-slider v-model="data.unit_font_size"
/>
</el-col>
</el-row>
<!-- 漸層方向-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層方向
</el-col>
<el-col :span="19">
<el-slider v-model="data.unit_color_gradient_direction"
:min="-200" :max="200"
:disabled="!data.unit_color2?true:false"
/>
</el-col>
</el-row>
<!-- 文字顏色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
文字顏色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.unit_color_gradient_position"
:disabled="!data.unit_color2?true:false"
/>
</el-col>
</el-row>
<!-- 漸層色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.unit_color2_gradient_position"
:disabled="!data.unit_color2?true:false"
/>
</el-col>
</el-row>
</section>
<!-- 符號-->
<section class="tw-space-y-5">
<p class="tw-text-xl tw-font-bold">符號</p>
<!-- 文字顏色/文字漸層色-->
<el-row class="tw-items-center">
<!-- 文字顏色-->
<el-col :span="5">
文字顏色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.symbol_color" show-alpha color-format="hex"
@active-change="colorChange('symbol_color',$event)"/>
</el-col>
<!-- 文字漸層色-->
<el-col :span="5">
文字漸層色
</el-col>
<el-col :span="7">
<el-color-picker v-model="data.symbol_color2" show-alpha color-format="hex"
@active-change="colorChange('symbol_color2',$event)"/>
</el-col>
</el-row>
<!-- 文字位置-->
<el-row class="tw-items-center">
<el-col :span="5">
文字位置
</el-col>
<el-col :span="19">
<el-slider v-model="data.symbol_y_position"
:min="-200" :max="200"
/>
</el-col>
</el-row>
<!-- 文字大小-->
<el-row class="tw-items-center">
<el-col :span="5">
文字大小
</el-col>
<el-col :span="19">
<el-slider v-model="data.symbol_font_size"
/>
</el-col>
</el-row>
<!-- 漸層方向-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層方向
</el-col>
<el-col :span="19">
<el-slider v-model="data.symbol_color_gradient_direction"
:min="-200" :max="200"
:disabled="!data.symbol_color2?true:false"
/>
</el-col>
</el-row>
<!-- 文字顏色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
文字顏色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.symbol_color_gradient_position"
:disabled="!data.symbol_color2?true:false"
/>
</el-col>
</el-row>
<!-- 漸層色起始-->
<el-row class="tw-items-center">
<el-col :span="5">
漸層色起始
</el-col>
<el-col :span="19">
<el-slider v-model="data.symbol_color2_gradient_position"
:disabled="!data.symbol_color2?true:false"
/>
</el-col>
</el-row>
</section>
</el-main>
</el-tab-pane>
</el-tabs>
</template>
<template #footer>
<el-row>
<el-col :span="2">
<el-button size="large" @click="toPrevious">返回列表</el-button>
</el-col>
<el-col :span="15"></el-col>
<el-col :span="5">
<el-button type="primary" size="large" plain @click="outputIframeCode">產出HTML代碼</el-button>
</el-col>
<el-col :span="2">
<el-button size="large" @click="toSaveRecord">保存</el-button>
</el-col>
</el-row>
<div class="tw-h-full tw-w-full tw-flex tw-justify-center">
</div>
</template>
</el-card>
</el-col>
<!-- preview-->
<el-col :span="10" class="tw-h-full">
<el-main class="tw-h-full ">
<!-- 樣式類型-->
<el-row class="tw-items-center">
<el-col :span="5">
樣式類型
</el-col>
<el-col :span="19">
<el-select v-model="data.timer_type" class="" placeholder="請選擇" size="large">
<el-option
v-for="item in timer_types"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-col>
</el-row>
<article class="tw-h-full tw-w-full tw-flex tw-items-center" style="height: 92%">
<countdown_timer_main :style="`height: ${data.height}px`" v-model="data" :dev="true" />
</article>
</el-main>
</el-col>
</el-row>
</el-main>
<el-dialog
v-model="iframe_code_dialog_show"
title="IFRAME HTML CODE"
width="60%"
align-center
>
<template #title>
<el-button type="primary" plain @click="copyIframeCode">複製</el-button>
</template>
<template #default>
<el-input
v-model="iframe_code"
:rows="15"
type="textarea"
class="-tw-my-5 tw-pb-5"
/>
</template>
</el-dialog>
<el-drawer v-model="images_drawer_show" title="選擇 / 上傳圖片" direction="ltr" size="50%" class="">
<template #default>
<section class="-tw-my-5 tw-space-y-7 tw-h-full">
<el-upload
drag
:http-request="toImageUpload"
:show-file-list="false"
>
<el-icon class="el-icon--upload">
<upload-filled/>
</el-icon>
<div class="el-upload__text">
拖移檔案至此 <em>點擊上傳</em>
</div>
</el-upload>
<el-card class="" style="height: 73%" :body-style="{height:'100%'}">
<div class="tw-grid tw-grid-cols-4 tw-gap-4 tw-h-full tw-overflow-auto">
<div v-for="(item,inedx) in store.state.countdown_timer.images" style="height: 150px">
<el-image :src="item.url" fit="fill"
:class="[item.url==data.bg_img_url?'tw-border-4 tw-border-blue-500':'','tw-h-full tw-w-full tw-rounded-md']"
@click="data.bg_img_url = item.url" loading="lazy"
/>
</div>
</div>
</el-card>
</section>
</template>
</el-drawer>
</template>
<script setup>
import {reactive, ref, watch} from "vue";
import { ElMessage } from 'element-plus'
import {useStore} from "vuex";
import countdown_timer_main from "@/module/conutdown-timer/components/card-one.vue"
import {useRoute, useRouter} from "vue-router";
const router = useRouter()
const route = useRoute()
const store = useStore();
const props = defineProps({
name:{
default:""
},
corporate_customer:{
default:''
},
timer_type:{
default:1
},
start_at:{
default:new Date()
},
end_at:{
default:new Date().setDate(new Date().getDate()+3)
},
height:{
default:150
},
repeat_day:{
default:0
},
bg_color: {
default: "#FFFFFFFF"
},
bg_color2: {
default: null
},
bg_color_gradient_direction: {
default: 62
},
bg_color_gradient_position: {
default: 0
},
bg_color2_gradient_position: {
default: 100
},
bg_img_url:{
default:""
},
header_switch: {
default: "Y"
},
header_text_prepare: {
default: "優 惠 活 動 尚 未 開 始"
},
header_text_ing: {
default: "優 惠 倒 數 計 時 中 "
},
header_text_finish: {
default: "優 惠 活 動 已 結 束"
},
header_y_position: {
default: 0
},
header_font_size: {
default: 20
},
header_color: {
default: "#000000FF"
},
header_color2: {
default: null
},
header_color_gradient_direction: {
default: 62
},
header_color_gradient_position: {
default: 0
},
header_color2_gradient_position: {
default: 100
},
timer_x_space: {
default: 10
},
time_y_position: {
default: 0
},
time_font_size: {
default: 20
},
time_color: {
default: "#000000FF"
},
time_color2: {
default: null
},
time_color_gradient_direction: {
default: 62
},
time_color_gradient_position: {
default: 0
},
time_color2_gradient_position: {
default: 100
},
symbol_y_position: {
default: 0
},
symbol_font_size: {
default: 20
},
symbol_color: {
default: "#000000FF"
},
symbol_color2: {
default: null
},
symbol_color_gradient_direction: {
default: 62
},
symbol_color_gradient_position: {
default: 0
},
symbol_color2_gradient_position: {
default: 100
},
unit_y_position: {
default: 0
},
unit_font_size: {
default: 20
},
unit_color: {
default: "#000000FF"
},
unit_color2: {
default: null
},
unit_color_gradient_direction: {
default: 62
},
unit_color_gradient_position: {
default: 0
},
unit_color2_gradient_position: {
default: 100
},
overlay_img: {
default: null
},
})
const queryString = route.query
const timer_types = reactive([
{
"label":1,
"value":1
}
])
const card_height = ref(150)
const iframe_code_dialog_show = ref(false)
const iframe_code = ref()
const images_drawer_show = ref(false)
const data = reactive({})
const page_loading = ref(false)
const initPage = async () => {
for (let key in props) {
data[key] = props[key]
}
data.corporate_customer = store.state.user.current_corporate_customer
if (store.state.countdown_timer_store.images.length == 0){
await store.dispatch('countdown_timer_store/getImages')
}
if (queryString.id) {
console.log(store.state.countdown_timer_store.edit_countdown_timer_data)
for (let key in data) {
data[key] = store.state.countdown_timer_store.edit_countdown_timer_data[key]
}
}
}
const colorChange = (param, color) => {
if (typeof param == 'string')
data[param] = color
else {
for (let i in param) {
data[param[i]] = color
}
}
}
const outputIframeCode = ()=>{
let query_obj = {}
for(let key in data){
const val = data[key]
if(val == null)
continue
query_obj[key] = val
}
console.log(query_obj)
let encodedURL = btoa(encodeURIComponent(JSON.stringify(query_obj)));
const src_url = `https://countdown-timer.ose.tw?params=${encodedURL}`
iframe_code.value = `<iframe src="${src_url}" height="${card_height.value}px" width="100%" style="border: 0px"></iframe>`
iframe_code_dialog_show.value=true
}
const copyIframeCode = ()=>{
let input = document.createElement('input')
//将input的值设置为需要复制的内容
input.value = iframe_code.value;
//添加input标签
document.body.appendChild(input)
//选中input标签
input.select()
//执行复制
document.execCommand('copy')
//移除input标签
document.body.removeChild(input)
ElMessage({
message: '複製完成!',
type: 'success',
})
}
const toImageUpload = async (e) => {
const form = new FormData();
form.append('file', e.file)
store.dispatch("countdown_timer/uploadImages", form)
}
const toPrevious = () => {
router.go(-1)
}
const toSaveRecord = async () => {
data.end_at = new Date (data.end_at).toISOString()
data.start_at = new Date (data.start_at).toISOString()
page_loading.value = true
let params = {
mode: queryString.mode,
id: queryString.id,
item: data
}
console.log({params})
const saveResult = await store.dispatch('countdown_timer_store/saveCountdownTimer', params)
ElMessage({
showClose: true,
message: '保存完成!',
type: 'success',
})
if (queryString.mode != 'edit')
router.go(-1)
page_loading.value = false
}
initPage()
</script>
<style>
.ose-setting-tabs > .el-tabs__content {
height: 92%;
}
.el-tabs__item {
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<el-main class="tw-h-full ">
<el-row class="tw-h-full">
<el-col class="tw-h-full">
<el-card shadow="never" class="tw-h-full " body-class="tw-h-full ">
<legacy-module-banner
title="conutdown-timer 目前仍是 legacy 倒數計時器模組"
description="這條線先維持既有營運功能,後續會在新平台主線穩定後,再整理它和 experiment / runtime / GTM 的關係。"
/>
<el-row class="tw-items-center">
<el-col :span="7">
<el-input
v-model="search"
size="large"
placeholder="搜尋"
suffix-icon="Search"/>
</el-col>
<el-col :span="2" :offset="15">
<el-button type="primary" size="large" :color="'#2f91ee'" @click="edit_page()" plain> 新增設定檔</el-button>
</el-col>
</el-row>
<el-divider></el-divider>
<el-table stripe border style="height: 87%" :data="filterTableData" v-loading="table_load"
:default-sort="{prop:'id',order:'descending'}">
<el-table-column v-for="(item,index) in table" :prop="item.id" :label="item.name"
:width="item.header_setup.width" :sortable="item.header_setup.sortable"
:show-overflow-tooltip="item.header_setup['show-overflow-tooltip']"/>
<el-table-column fixed="right" label="操作" width="220" >
<template #default="scope">
<section class="tw-grid tw-grid-cols-3 tw-gap-2">
<div class="">
<el-button size="default" @click="toEdit(scope.row)"
>編輯</el-button>
</div>
<div class="">
<el-button size="default" @click="toCopy(scope.row)"
>複製</el-button>
</div>
<div class="">
<el-button size="default" @click="toDelete(scope.row)"
>刪除</el-button>
</div>
</section>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</el-main>
</template>
<script setup>
import LegacyModuleBanner from "@/components/LegacyModuleBanner.vue";
import {ref, reactive, inject, provide, computed} from 'vue'
import {useRouter} from "vue-router";
import {useStore} from "vuex";
import { ElMessage, ElMessageBox } from 'element-plus'
import table from "@/module/conutdown-timer/model/table";
import { buildOseCardPreviewUrl } from "@/module/ose-card/service/card-url";
const store = useStore();
const router = useRouter()
const search = ref()
const table_load = ref(true)
const filterTableData = computed(()=>
store.state.countdown_timer_store.countdown_timer_list.filter((data) => {
if (!search.value)
return data
for(let index in table){
try {
if(data[table[index].id].toLowerCase().includes(search.value.toLowerCase()))
return data
}catch (e) {
continue
}
}
})
)
const edit_page = (queryParams)=>{
table_load.value = true
router.push({
name:"conutdown-timer-edit",
query:queryParams
})
table_load.value = false
}
const init_page = async ()=>{
table_load.value = true
await store.dispatch('countdown_timer_store/getCountdownTimerList',store.state.user.current_corporate_customer)
table_load.value = false
}
const toPreview = (row_item)=>{
window.open(buildOseCardPreviewUrl(row_item.landing_page, row_item.card_code))
}
const toEdit = (row_item)=>{
store.commit('countdown_timer_store/edit_countdown_timer_data',row_item)
edit_page({
id:row_item.id,
mode:"edit"
})
}
const toCopy = (row_item)=>{
store.commit('countdown_timer_store/edit_countdown_timer_data',row_item)
edit_page({
id:row_item.id,
mode:"copy"
})
}
const toDelete = async (row_item)=>{
ElMessageBox.confirm(
'請再次確認是否該刪除?',
'注意',
{
confirmButtonText: '確認',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
table_load.value = true
await store.dispatch('countdown_timer_store/deleteCountdownTimer',row_item.id)
await store.dispatch('countdown_timer_store/getCountdownTimerList',store.state.user.current_corporate_customer)
ElMessage({
type: 'success',
message: '刪除成功',
})
table_load.value = false
})
.catch(() => {
ElMessage({
type: 'info',
message: '刪除取消',
})
})
}
init_page()
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,336 @@
<template>
<AdminPageShell
eyebrow="平台總覽"
title="行銷平台儀表板"
description="管理銷售實驗、視覺編輯器與行銷模組的統一入口。"
>
<template #actions>
<div class="company-badge">{{ currentCorporateCustomerName }}</div>
</template>
<template #summary>
<div class="metrics-grid">
<AdminMetricCard label="實驗總數" :value="experimentSummary.total" hint="目前已建立的實驗數量" />
<AdminMetricCard label="進行中" :value="experimentSummary.running" tone="success" hint="目前正在運行中的實驗" />
<AdminMetricCard label="草稿中" :value="experimentSummary.draft" tone="warning" hint="尚未上線、待整理的草稿" />
<AdminMetricCard label="已結束 / 暫停" :value="experimentSummary.ended" tone="danger" hint="已結束或暫停中的實驗" />
</div>
</template>
<div class="content-grid">
<!-- 主線 -->
<div class="card" v-loading="loadingExperiments">
<p class="card__eyebrow">目前主線</p>
<h3 class="card__title">銷售實驗主線</h3>
<p class="card__desc">建立並管理 A/B 實驗設定各版本變體用視覺編輯器調整頁面內容再發佈版本上線</p>
<div class="exp-list">
<div
v-for="exp in recentExperiments"
:key="exp.id"
class="exp-item"
@click="goToExperiment(exp.id)"
>
<span class="exp-item__name">{{ exp.name }}</span>
<el-tag :type="statusTypeMap[exp.status] || 'info'" effect="light" size="small">
{{ statusLabelMap[exp.status] || exp.status }}
</el-tag>
</div>
<div v-if="recentExperiments.length === 0 && !loadingExperiments" class="exp-empty">
尚未建立實驗
</div>
</div>
<div class="card__actions">
<el-button type="primary" @click="goToExperiments">前往銷售實驗</el-button>
<el-button @click="openEditorDemo">預覽示範頁</el-button>
</div>
</div>
<!-- 舊版模組 -->
<div class="card card--legacy">
<p class="card__eyebrow">舊版模組</p>
<h3 class="card__title">既有行銷模組</h3>
<p class="card__desc">行銷圖卡與倒數計時器目前持續可用與新實驗主線並行運作</p>
<div class="tag-group">
<el-tag type="warning" effect="light">ose-card</el-tag>
<el-tag type="warning" effect="light">conutdown-timer</el-tag>
</div>
<div class="card__actions">
<el-button @click="goToOseCard">前往行銷圖卡</el-button>
<el-button @click="goToCountdownTimer">前往倒數計時器</el-button>
</div>
</div>
<!-- 完成度 -->
<div class="card">
<p class="card__eyebrow">主線進度</p>
<h3 class="card__title">目前完成度</h3>
<div class="progress-list">
<div v-for="item in progressItems" :key="item.label" class="progress-item">
<div class="progress-item__dot" :class="{ 'progress-item__dot--done': item.done }">
{{ item.done ? '✓' : '○' }}
</div>
<span class="progress-item__label">{{ item.label }}</span>
</div>
</div>
</div>
</div>
<div class="bottom-grid">
<div class="card">
<p class="card__section-title">平台資訊架構</p>
<el-steps :active="4" finish-status="success" align-center>
<el-step title="Dashboard" description="登入後首頁與主線總覽" />
<el-step title="銷售實驗" description="新主線管理入口" />
<el-step title="變體 / 版本" description="管理變體與版本節點" />
<el-step title="編輯器" description="Visual Editor 全頁編輯" />
</el-steps>
</div>
<div class="card">
<p class="card__section-title">目前使用者上下文</p>
<el-descriptions :column="1" border>
<el-descriptions-item label="登入帳號">{{ currentUserEmail }}</el-descriptions-item>
<el-descriptions-item label="目前公司">{{ currentCorporateCustomerName }}</el-descriptions-item>
<el-descriptions-item label="模式">新舊並行</el-descriptions-item>
</el-descriptions>
</div>
</div>
</AdminPageShell>
</template>
<script setup>
import AdminMetricCard from "@/components/AdminMetricCard.vue"
import AdminPageShell from "@/components/AdminPageShell.vue"
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { useStore } from "vuex"
import experimentApi from "@/module/experiment/service/experiment-api"
import { mapExperimentListItem, statusLabelMap, statusTypeMap, summarizeExperiments } from "@/module/experiment/model/experiment-view-model"
const router = useRouter()
const store = useStore()
const experiments = ref([])
const loadingExperiments = ref(false)
const experimentSummary = computed(() => summarizeExperiments(experiments.value))
const recentExperiments = computed(() => experiments.value.slice(0, 4))
const currentUserEmail = computed(
() => store.state.user.user_info?.email || "未載入"
)
const currentCorporateCustomerName = computed(
() => store.state.user.current_corporate_customer_name || "請選擇公司"
)
const progressItems = [
{ label: "視覺編輯器", done: true },
{ label: "實驗 / 變體建立與編輯", done: true },
{ label: "版本建立、發佈、回退", done: true },
{ label: "Runtime API", done: true },
{ label: "Staging 真實環境驗證", done: true },
{ label: "SDK / GTM 實裝", done: false },
]
const goToExperiments = () => {
router.push({ name: "experiments" })
}
const goToExperiment = (id) => {
router.push({ name: "experiment-detail", params: { experimentId: id } })
}
const openEditorDemo = () => {
if (typeof window === "undefined") return
window.open(`${window.location.origin}/editor-demo.html`, "_blank")
}
const goToOseCard = () => {
router.push({ name: "ose-card" })
}
const goToCountdownTimer = () => {
router.push({ name: "conutdown-timer-list" })
}
onMounted(async () => {
loadingExperiments.value = true
try {
const items = await experimentApi.listExperiments()
experiments.value = items.map(mapExperimentListItem)
} catch {
experiments.value = []
} finally {
loadingExperiments.value = false
}
})
</script>
<style scoped>
.company-badge {
padding: 6px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #374151;
font-size: 13px;
font-weight: 500;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
/* Card */
.card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 22px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card--legacy {
background: #f8fafc;
border-color: #e2e8f0;
opacity: 0.88;
}
.card__eyebrow {
margin: 0;
color: #94a3b8;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.card__title {
margin: 0;
color: #0f172a;
font-size: 17px;
font-weight: 700;
}
.card__desc {
margin: 0;
color: #64748b;
font-size: 13.5px;
line-height: 1.7;
}
.card__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: auto;
padding-top: 4px;
}
.card__section-title {
margin: 0 0 16px;
color: #0f172a;
font-size: 14px;
font-weight: 600;
}
/* Grid layouts */
.content-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.bottom-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
/* Experiment list */
.exp-list {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.exp-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
border: 1px solid #f1f5f9;
border-radius: 10px;
background: #f8fafc;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.exp-item:hover {
background: #f1f5f9;
border-color: #e2e8f0;
}
.exp-item__name {
font-size: 13.5px;
font-weight: 500;
color: #1e293b;
}
.exp-empty {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
/* Tag group */
.tag-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Progress list */
.progress-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-item {
display: flex;
align-items: center;
gap: 10px;
}
.progress-item__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
background: #f1f5f9;
color: #94a3b8;
}
.progress-item__dot--done {
background: #d1fae5;
color: #059669;
}
.progress-item__label {
font-size: 13px;
color: #475569;
}
</style>

View File

@@ -0,0 +1,705 @@
<template>
<div class="editor-canvas-frame">
<div v-if="baseUrl" class="editor-canvas-frame__viewport" :class="viewportClass">
<iframe
ref="frameRef"
class="editor-canvas-frame__iframe"
:src="baseUrl"
title="editor-canvas"
loading="lazy"
@load="handleFrameLoad"
/>
<div
class="editor-canvas-frame__status-pill"
:class="`editor-canvas-frame__status-pill--${canvasStatusTag.type}`"
>
<span class="editor-canvas-frame__status-dot" />
{{ canvasStatusTag.label }}
</div>
</div>
<div v-else class="editor-canvas-frame__empty">
<p class="editor-canvas-frame__empty-title">先填入要編輯的頁面網址</p>
<p class="editor-canvas-frame__empty-body">
畫布會直接載入目標頁面之後所有可視化操作都會從這裡發生
</p>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
import {
createStatusSummaryMessage,
createFocusSelectorMessage,
createInspectSelectorMessage,
createStartInlineEditMessage,
createMoveBlockMessage,
createToggleBlockVisibilityMessage,
createDuplicateBlockMessage,
createDeleteBlockMessage,
createInsertBlockMessage,
createRestoreCanvasStateMessage,
createPreviewOperationsMessage,
editorBridgeEventType,
normalizeCanvasSelectionPayload,
} from "@/module/editor/model/editor-bridge-protocol"
const props = defineProps({
baseUrl: {
type: String,
default: "",
},
deviceMode: {
type: String,
default: "desktop",
},
candidates: {
type: Array,
default: () => [],
},
selectedSelector: {
type: String,
default: "",
},
previewStatus: {
type: Object,
default: () => ({
title: "",
status: "idle",
}),
},
previewOperations: {
type: Array,
default: () => [],
},
feedbackSummary: {
type: Object,
default: () => ({
title: "",
selectorLabel: "",
detail: "",
helper: "",
status: "info",
}),
},
bridgeCommand: {
type: Object,
default: null,
},
})
const emit = defineEmits([
"select-candidate",
"hover-candidate",
"hover-clear",
"hotspot-action",
"inline-text-change",
"inline-attribute-change",
"inline-style-change",
"canvas-ready",
"bridge-outbound",
])
const frameRef = ref(null)
const canvasState = ref("idle")
const canvasOrigin = ref("")
const lastHoverSelector = ref("")
const lastIncomingSelector = ref("")
const lastFocusedSelector = ref("")
const lastOutboundCommand = ref("")
const viewportClass = computed(
() => `editor-canvas-frame__viewport--${props.deviceMode || "desktop"}`
)
const canvasStatusTag = computed(() => {
switch (canvasState.value) {
case "connected":
return {
type: "success",
label: "已連線",
}
case "loaded":
return {
type: "info",
label: "頁面已載入",
}
default:
return {
type: "warning",
label: props.baseUrl ? "等待編輯橋接" : "尚未啟動",
}
}
})
const handleFrameLoad = () => {
canvasState.value = "loaded"
canvasOrigin.value = ""
pushSelectedSelectorToCanvas()
}
const postBridgeMessage = (message, meta = {}) => {
if (!frameRef.value?.contentWindow || !message?.type) {
return
}
if (
[
editorBridgeEventType.FOCUS_SELECTOR,
editorBridgeEventType.INSPECT_SELECTOR,
editorBridgeEventType.START_INLINE_EDIT,
editorBridgeEventType.MOVE_BLOCK,
editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
editorBridgeEventType.DUPLICATE_BLOCK,
editorBridgeEventType.DELETE_BLOCK,
editorBridgeEventType.INSERT_BLOCK,
].includes(message.type) &&
!message?.selector
) {
return
}
frameRef.value.contentWindow.postMessage(message, canvasOrigin.value || "*")
if (message.selector) {
lastFocusedSelector.value = message.selector
}
lastOutboundCommand.value = meta.label || message.type
emit("bridge-outbound", {
type: meta.logType || message.type,
value: message.selector,
source: meta.source || "editor-workspace",
})
}
const pushSelectedSelectorToCanvas = () => {
if (!props.selectedSelector || !frameRef.value?.contentWindow) {
return
}
postBridgeMessage(
createFocusSelectorMessage({
selector: props.selectedSelector,
}),
{
label: "已同步焦點到畫布",
logType: "focus-selector",
source: "editor-workspace",
}
)
}
const runBridgeCommand = (command) => {
if (!command?.type) {
return
}
if (
command.type !== editorBridgeEventType.RESTORE_CANVAS_STATE &&
!command?.selector
) {
return
}
if (command.type === editorBridgeEventType.INSPECT_SELECTOR) {
postBridgeMessage(
createInspectSelectorMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求畫布檢查區塊",
logType: "inspect-selector",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.START_INLINE_EDIT) {
postBridgeMessage(
createStartInlineEditMessage({
selector: command.selector,
editKind: command.editKind,
attributeName: command.attributeName,
attributeValue: command.attributeValue,
styleProperty: command.styleProperty,
styleValue: command.styleValue,
source: command.source,
}),
{
label: "已要求畫布進入頁內文字編輯",
logType: "start-inline-edit",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.MOVE_BLOCK) {
postBridgeMessage(
createMoveBlockMessage({
selector: command.selector,
direction: command.direction,
source: command.source,
}),
{
label: command.direction === "up" ? "已要求區塊上移" : "已要求區塊下移",
logType: "move-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY) {
postBridgeMessage(
createToggleBlockVisibilityMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已切換區塊顯示狀態",
logType: "toggle-block-visibility",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.DUPLICATE_BLOCK) {
postBridgeMessage(
createDuplicateBlockMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求複製區塊",
logType: "duplicate-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.DELETE_BLOCK) {
postBridgeMessage(
createDeleteBlockMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求刪除區塊",
logType: "delete-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.INSERT_BLOCK) {
postBridgeMessage(
createInsertBlockMessage({
selector: command.selector,
position: command.position,
blockType: command.blockType,
source: command.source,
}),
{
label:
command.position === "before"
? `已要求前面插入${command.blockTypeLabel || "區塊"}`
: `已要求後面插入${command.blockTypeLabel || "區塊"}`,
logType: "insert-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.RESTORE_CANVAS_STATE) {
postBridgeMessage(
createRestoreCanvasStateMessage({
pageHtml: command.pageHtml,
selector: command.selector,
source: command.source,
}),
{
label: "已還原畫布狀態",
logType: "restore-canvas-state",
source: command.source || "editor-workspace",
}
)
return
}
postBridgeMessage(
createFocusSelectorMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已同步焦點到畫布",
logType: "focus-selector",
source: command.source || "editor-workspace",
}
)
}
const pushFeedbackSummaryToCanvas = () => {
if (!frameRef.value?.contentWindow || !props.feedbackSummary?.title) {
return
}
postBridgeMessage(
createStatusSummaryMessage({
mode: props.feedbackSummary.mode,
modeLabel: props.feedbackSummary.modeLabel,
status: props.feedbackSummary.status,
title: props.feedbackSummary.title,
selector: props.feedbackSummary.selectorLabel,
detail: props.feedbackSummary.detail,
helper: props.feedbackSummary.helper,
primaryActionLabel: props.feedbackSummary.primaryActionLabel,
secondaryActionLabel: props.feedbackSummary.secondaryActionLabel,
}),
{
label: "已同步畫布回饋摘要",
logType: "status-summary",
source: "editor-workspace",
}
)
}
const pushPreviewOperationsToCanvas = () => {
if (!frameRef.value?.contentWindow) {
return
}
postBridgeMessage(
createPreviewOperationsMessage({
operations: props.previewOperations,
}),
{
label: "已同步預覽結果到畫布",
logType: "preview-operations",
source: "editor-workspace",
}
)
}
const handleMessage = (event) => {
if (!props.baseUrl || !frameRef.value?.contentWindow || event.source !== frameRef.value.contentWindow) {
return
}
const payload = event.data || {}
if (payload?.type === editorBridgeEventType.READY) {
canvasState.value = "connected"
canvasOrigin.value = event.origin || ""
emit("canvas-ready", payload)
pushPreviewOperationsToCanvas()
return
}
if (payload?.type === editorBridgeEventType.HOVER && payload.selector) {
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
canvasState.value = "connected"
lastHoverSelector.value = normalizedPayload.selector
emit("hover-candidate", normalizedPayload)
return
}
if (payload?.type === editorBridgeEventType.HOVER_CLEAR) {
lastHoverSelector.value = ""
emit("hover-clear")
return
}
if (payload?.type === editorBridgeEventType.HOTSPOT_ACTION) {
emit("hotspot-action", {
action: payload.action || "",
selector: payload.selector || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
intentChangeType: payload.intent_change_type || "",
intentField: payload.intent_field || "",
intentAttributeName: payload.intent_attribute_name || "",
intentAttributeValue: payload.intent_attribute_value || "",
source: payload.source || "canvas-hotspot",
})
emit("bridge-outbound", {
type: payload.action || payload.type,
value: payload.selector || "",
source: payload.source || "canvas-hotspot",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_TEXT_CHANGE && payload.selector) {
emit("inline-text-change", {
selector: payload.selector || "",
text: payload.text || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_ATTRIBUTE_CHANGE && payload.selector) {
emit("inline-attribute-change", {
selector: payload.selector || "",
attributeName: payload.attribute_name || "",
attributeValue: payload.attribute_value || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_STYLE_CHANGE && payload.selector) {
emit("inline-style-change", {
selector: payload.selector || "",
styleProperty: payload.style_property || "",
styleValue: payload.style_value || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.BLOCK_VISIBILITY_CHANGE && payload.selector) {
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-bridge",
hidden: Boolean(payload.hidden),
pageHtml: payload.page_html || "",
})
emit("select-candidate", normalizeCanvasSelectionPayload(payload))
return
}
if (payload?.type === editorBridgeEventType.BLOCK_STRUCTURE_CHANGE) {
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-bridge",
action: payload.action || "",
nextSelector: payload.next_selector || "",
blockType: payload.block_type || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
hidden: Boolean(payload.hidden),
pageHtml: payload.page_html || "",
})
return
}
if (payload?.type === editorBridgeEventType.SELECTION && payload.selector) {
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
canvasState.value = "connected"
lastIncomingSelector.value = normalizedPayload.selector
emit("select-candidate", normalizedPayload)
}
}
watch(
() => props.baseUrl,
(value) => {
canvasState.value = value ? "idle" : "idle"
lastHoverSelector.value = ""
lastIncomingSelector.value = ""
lastFocusedSelector.value = ""
}
)
watch(
() => props.selectedSelector,
() => {
pushSelectedSelectorToCanvas()
}
)
watch(
() => props.previewOperations,
() => {
pushPreviewOperationsToCanvas()
},
{ deep: true }
)
watch(
() => props.feedbackSummary,
() => {
pushFeedbackSummaryToCanvas()
},
{ deep: true }
)
watch(
() => props.bridgeCommand,
(value) => {
if (!value) {
return
}
runBridgeCommand(value)
},
{ deep: true }
)
onMounted(() => {
window.addEventListener("message", handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener("message", handleMessage)
})
</script>
<style scoped>
.editor-canvas-frame {
min-height: 100%;
}
.editor-canvas-frame__viewport {
position: relative;
min-height: calc(100vh - 72px);
display: flex;
justify-content: center;
align-items: flex-start;
overflow: hidden;
background-color: #e2eaf4;
background-image: radial-gradient(rgba(100, 116, 139, 0.18) 1px, transparent 1px);
background-size: 22px 22px;
}
.editor-canvas-frame__iframe {
width: 100%;
min-height: calc(100vh - 72px);
border: 0;
background: white;
}
.editor-canvas-frame__viewport--desktop .editor-canvas-frame__iframe {
width: 100%;
}
.editor-canvas-frame__viewport--tablet .editor-canvas-frame__iframe {
width: min(820px, 100%);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.1), 0 8px 32px rgba(15, 23, 42, 0.12);
}
.editor-canvas-frame__viewport--mobile .editor-canvas-frame__iframe {
width: min(430px, 100%);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12), 0 12px 36px rgba(15, 23, 42, 0.14);
}
.editor-canvas-frame__empty {
display: grid;
place-items: center;
min-height: 360px;
padding: 24px;
border: 1px dashed rgba(148, 163, 184, 0.42);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98));
text-align: center;
}
.editor-canvas-frame__empty-title {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.editor-canvas-frame__empty-body {
max-width: 460px;
margin: 10px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.75;
}
.editor-canvas-frame__status-pill {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
pointer-events: none;
backdrop-filter: blur(6px);
transition: opacity 0.3s;
}
.editor-canvas-frame__status-pill--success {
background: rgba(220, 252, 231, 0.88);
color: #15803d;
border: 1px solid rgba(134, 239, 172, 0.5);
}
.editor-canvas-frame__status-pill--info {
background: rgba(224, 242, 254, 0.88);
color: #0369a1;
border: 1px solid rgba(147, 197, 253, 0.5);
}
.editor-canvas-frame__status-pill--warning {
background: rgba(254, 249, 195, 0.88);
color: #92400e;
border: 1px solid rgba(253, 224, 71, 0.5);
}
.editor-canvas-frame__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.editor-canvas-frame__status-pill--success .editor-canvas-frame__status-dot {
background: #16a34a;
}
.editor-canvas-frame__status-pill--info .editor-canvas-frame__status-dot {
background: #0284c7;
}
.editor-canvas-frame__status-pill--warning .editor-canvas-frame__status-dot {
background: #d97706;
animation: status-pulse 1.6s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<header class="editor-top-toolbar">
<div class="editor-top-toolbar__actions">
<!-- 裝置切換 -->
<div class="editor-top-toolbar__group">
<button
v-for="dev in deviceOptions"
:key="dev.value"
class="editor-top-toolbar__icon-btn"
:class="{ 'is-active': deviceMode === dev.value }"
:title="dev.label"
@click="$emit('set-device-mode', dev.value)"
>
<component :is="dev.icon" />
</button>
</div>
<!-- 結構操作 -->
<div class="editor-top-toolbar__group">
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
:title="selectedHidden ? '顯示區塊' : '隱藏區塊'"
@click="$emit('toggle-visibility')"
>
<EyeOffIcon v-if="!selectedHidden" />
<EyeIcon v-else />
<span>{{ selectedHidden ? "顯示" : "隱藏" }}</span>
</button>
<div class="editor-top-toolbar__insert-row">
<el-select
:model-value="insertBlockType"
class="editor-top-toolbar__insert-type"
placeholder="插入類型"
@change="$emit('update:insert-block-type', $event)"
>
<el-option
v-for="option in insertBlockTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="前面插入"
@click="$emit('insert-before')"
>
<InsertBeforeIcon />
<span>前插</span>
</button>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="後面插入"
@click="$emit('insert-after')"
>
<InsertAfterIcon />
<span>後插</span>
</button>
</div>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="複製區塊"
@click="$emit('duplicate')"
>
<CopyIcon />
<span>複製</span>
</button>
<button
class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--danger"
:disabled="!selectedSelector"
title="刪除區塊"
@click="$emit('delete')"
>
<TrashIcon />
<span>刪除</span>
</button>
</div>
<!-- 歷史 -->
<div class="editor-top-toolbar__group">
<button
class="editor-top-toolbar__icon-btn"
:disabled="!canUndo"
title="上一步Ctrl+Z"
@click="$emit('undo')"
>
<UndoIcon />
</button>
<button
class="editor-top-toolbar__icon-btn"
:disabled="!canRedo"
title="下一步Ctrl+Y"
@click="$emit('redo')"
>
<RedoIcon />
</button>
</div>
</div>
<div class="editor-top-toolbar__secondary">
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="載入示範頁" @click="$emit('load-demo')">
示範
</button>
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="返回" @click="$emit('back')">
<BackIcon />
<span>返回</span>
</button>
</div>
</header>
</template>
<script setup>
import { defineComponent, h } from "vue"
defineProps({
deviceMode: { type: String, default: "desktop" },
selectedSelector: { type: String, default: "" },
selectedHidden: { type: Boolean, default: false },
insertBlockType: { type: String, default: "content-card" },
insertBlockTypeOptions: { type: Array, default: () => [] },
canUndo: { type: Boolean, default: false },
canRedo: { type: Boolean, default: false },
})
defineEmits([
"set-device-mode",
"toggle-visibility",
"update:insert-block-type",
"insert-before",
"insert-after",
"duplicate",
"delete",
"undo",
"redo",
"back",
"load-demo",
])
function svgIcon(pathD, vb) {
return defineComponent({
render() {
return h("svg", { width: 18, height: 18, viewBox: vb || "0 0 20 20", fill: "none", "aria-hidden": "true" },
[h("path", { d: pathD, fill: "currentColor" })]
)
}
})
}
const DesktopIcon = svgIcon("M3 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H3V4Zm1 11h12l1 2H3l1-2Zm3-1h6v1H7v-1Z")
const TabletIcon = svgIcon("M5 3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H5Zm5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
const MobileIcon = svgIcon("M7 2a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7Zm3 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
const UndoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7H13a4 4 0 0 1 0 8H9", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M7 4 4 7l3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const RedoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M15 7H7a4 4 0 0 0 0 8h4", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M13 4l3 3-3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const EyeIcon = svgIcon("M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Zm7 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z")
const EyeOffIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Z", stroke: "currentColor", "stroke-width": "1.6" }), h("line", { x1: "4", y1: "4", x2: "16", y2: "16", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" })]) } })
const CopyIcon = svgIcon("M7 4h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM4 7H3a1 1 0 0 0-1 1v8a2 2 0 0 0 2 2h9a1 1 0 0 0 1-1v-1")
const TrashIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7h10l-1 10H6L5 7ZM3 7h14M8 7V4h4v3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const InsertBeforeIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "3", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 11v6M7 14l3-3 3 3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const InsertAfterIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "12", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 9V3M7 6l3 3 3-3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const BackIcon = svgIcon("M10 4 4 10l6 6M4 10h12")
const deviceOptions = [
{ value: "desktop", label: "桌機", icon: DesktopIcon },
{ value: "tablet", label: "平板", icon: TabletIcon },
{ value: "mobile", label: "手機", icon: MobileIcon },
]
</script>
<style scoped>
.editor-top-toolbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
backdrop-filter: blur(18px);
background: rgba(250, 251, 253, 0.96);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.editor-top-toolbar__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
flex: 1;
}
.editor-top-toolbar__secondary {
display: flex;
gap: 6px;
align-items: center;
flex-shrink: 0;
}
/* 分組容器 */
.editor-top-toolbar__group {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
/* 插入列 — 把 select 和兩個按鈕放一排 */
.editor-top-toolbar__insert-row {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* icon-only 按鈕裝置切換、undo/redo */
.editor-top-toolbar__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 9px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.editor-top-toolbar__icon-btn:hover:not(:disabled) {
background: #f0f5ff;
color: #1d4ed8;
}
.editor-top-toolbar__icon-btn.is-active {
background: #e8f0ff;
color: #1d4ed8;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.14);
}
.editor-top-toolbar__icon-btn:disabled {
opacity: 0.38;
cursor: default;
}
/* text + icon 按鈕 */
.editor-top-toolbar__text-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 34px;
padding: 0 11px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 9px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.editor-top-toolbar__text-btn:hover:not(:disabled) {
background: #f0f5ff;
border-color: rgba(59, 130, 246, 0.28);
color: #1d4ed8;
}
.editor-top-toolbar__text-btn:disabled {
opacity: 0.38;
cursor: default;
background: #f8fafc;
}
/* 危險按鈕 — 刪除 */
.editor-top-toolbar__text-btn--danger:hover:not(:disabled) {
background: #fff5f5;
border-color: rgba(239, 68, 68, 0.32);
color: #dc2626;
}
/* 次要按鈕 — 返回/示範 */
.editor-top-toolbar__text-btn--ghost {
background: transparent;
border-color: transparent;
color: #64748b;
}
.editor-top-toolbar__text-btn--ghost:hover:not(:disabled) {
background: #f1f5f9;
border-color: rgba(148, 163, 184, 0.24);
color: #334155;
}
/* insert type select */
.editor-top-toolbar__insert-type {
width: 116px;
}
.editor-top-toolbar :deep(.el-select__wrapper),
.editor-top-toolbar :deep(.el-input__wrapper) {
height: 34px;
min-height: 34px;
border-radius: 9px;
box-shadow: none;
border: 1px solid rgba(148, 163, 184, 0.2);
background: #fff;
font-size: 13px;
}
@media (max-width: 1200px) {
.editor-top-toolbar {
flex-direction: column;
align-items: stretch;
}
.editor-top-toolbar__actions {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,118 @@
export const editorBridgeEventType = {
READY: "mkt-editor:ready",
HOVER: "mkt-editor:hover",
HOVER_CLEAR: "mkt-editor:hover-clear",
SELECTION: "mkt-editor:selection",
HOTSPOT_ACTION: "mkt-editor:hotspot-action",
FOCUS_SELECTOR: "mkt-editor:focus-selector",
INSPECT_SELECTOR: "mkt-editor:inspect-selector",
START_INLINE_EDIT: "mkt-editor:start-inline-edit",
MOVE_BLOCK: "mkt-editor:move-block",
TOGGLE_BLOCK_VISIBILITY: "mkt-editor:toggle-block-visibility",
DUPLICATE_BLOCK: "mkt-editor:duplicate-block",
DELETE_BLOCK: "mkt-editor:delete-block",
INSERT_BLOCK: "mkt-editor:insert-block",
RESTORE_CANVAS_STATE: "mkt-editor:restore-canvas-state",
INLINE_TEXT_CHANGE: "mkt-editor:inline-text-change",
INLINE_ATTRIBUTE_CHANGE: "mkt-editor:inline-attribute-change",
INLINE_STYLE_CHANGE: "mkt-editor:inline-style-change",
BLOCK_VISIBILITY_CHANGE: "mkt-editor:block-visibility-change",
BLOCK_STRUCTURE_CHANGE: "mkt-editor:block-structure-change",
STATUS_SUMMARY: "mkt-editor:status-summary",
PREVIEW_OPERATIONS: "mkt-editor:preview-operations",
}
export const normalizeCanvasSelectionPayload = (payload = {}) => ({
selector: payload.selector || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
hidden: Boolean(payload.hidden),
source: payload.source || "canvas-bridge",
})
export const createFocusSelectorMessage = (payload = {}) => ({
type: editorBridgeEventType.FOCUS_SELECTOR,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createInspectSelectorMessage = (payload = {}) => ({
type: editorBridgeEventType.INSPECT_SELECTOR,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createStartInlineEditMessage = (payload = {}) => ({
type: editorBridgeEventType.START_INLINE_EDIT,
selector: payload.selector || "",
editKind: payload.editKind || "",
attributeName: payload.attributeName || "",
attributeValue: payload.attributeValue || "",
styleProperty: payload.styleProperty || "",
styleValue: payload.styleValue || "",
source: payload.source || "editor-workspace",
})
export const createMoveBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.MOVE_BLOCK,
selector: payload.selector || "",
direction: payload.direction || "down",
source: payload.source || "editor-workspace",
})
export const createToggleBlockVisibilityMessage = (payload = {}) => ({
type: editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createDuplicateBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.DUPLICATE_BLOCK,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createDeleteBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.DELETE_BLOCK,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createInsertBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.INSERT_BLOCK,
selector: payload.selector || "",
position: payload.position || "after",
blockType: payload.blockType || "content-card",
source: payload.source || "editor-workspace",
})
export const createRestoreCanvasStateMessage = (payload = {}) => ({
type: editorBridgeEventType.RESTORE_CANVAS_STATE,
pageHtml: payload.pageHtml || "",
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createStatusSummaryMessage = (payload = {}) => ({
type: editorBridgeEventType.STATUS_SUMMARY,
mode: payload.mode || "idle",
modeLabel: payload.modeLabel || "",
status: payload.status || "info",
title: payload.title || "",
selector: payload.selector || "",
detail: payload.detail || "",
helper: payload.helper || "",
primaryActionLabel: payload.primaryActionLabel || "",
secondaryActionLabel: payload.secondaryActionLabel || "",
source: payload.source || "editor-workspace",
})
export const createPreviewOperationsMessage = (payload = {}) => ({
type: editorBridgeEventType.PREVIEW_OPERATIONS,
operations: Array.isArray(payload.operations)
? JSON.parse(JSON.stringify(payload.operations))
: [],
source: payload.source || "editor-workspace",
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import fastapiClient from "@/services/api/fastapi-client";
const createEditorSession = async (payload) => {
return await fastapiClient.request("/api/editor/sessions", {
method: "POST",
body: JSON.stringify(payload),
});
};
const getVariantChanges = async (variantId) => {
const response = await fastapiClient.request(
`/api/editor/variants/${variantId}/changes`
);
return response.items || [];
};
const saveVariantChanges = async (variantId, items) => {
const response = await fastapiClient.request(
`/api/editor/variants/${variantId}/changes`,
{
method: "PUT",
body: JSON.stringify({ items }),
}
);
return response.items || [];
};
const buildPreview = async (payload) => {
return await fastapiClient.request("/api/editor/previews/build", {
method: "POST",
body: JSON.stringify(payload),
});
};
export default {
createEditorSession,
getVariantChanges,
saveVariantChanges,
buildPreview,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
<template>
<section class="fullscreen-editor">
<EditorTopToolbar
:device-mode="deviceMode"
:selected-selector="selectedSelector"
:selected-hidden="selectedElementMeta.hidden"
:insert-block-type="insertBlockType"
:insert-block-type-options="insertBlockTypeOptions"
:can-undo="canUndo"
:can-redo="canRedo"
@set-device-mode="setDeviceMode"
@toggle-visibility="toggleSelectedBlockVisibility"
@update:insert-block-type="insertBlockType = $event"
@insert-before="insertBlockNearSelection('before')"
@insert-after="insertBlockNearSelection('after')"
@duplicate="duplicateSelectedBlock"
@delete="deleteSelectedBlock"
@undo="undoHistory"
@redo="redoHistory"
@back="goBack"
@load-demo="loadDemoPage"
/>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
:title="errorMessage"
show-icon
/>
<section class="fullscreen-editor__stage">
<div class="fullscreen-editor__canvas-shell">
<EditorCanvasFrame
:base-url="baseUrl"
:device-mode="deviceMode"
:candidates="canvasCandidates"
:selected-selector="selectedSelector"
:preview-status="selectedPreviewSummary"
:preview-operations="previewOperations"
:feedback-summary="canvasFeedbackSummary"
:bridge-command="canvasBridgeCommand"
@select-candidate="selectCanvasCandidate"
@hover-candidate="handleCanvasHover"
@hover-clear="keepCurrentSelection"
@hotspot-action="handleCanvasHotspotAction"
@inline-attribute-change="handleInlineAttributeChange"
@inline-style-change="handleInlineStyleChange"
@inline-text-change="handleInlineTextChange"
@canvas-ready="handleCanvasReady"
@bridge-outbound="handleCanvasOutbound"
/>
</div>
</section>
</section>
</template>
<script setup>
import { onBeforeUnmount, onMounted } from "vue"
import EditorCanvasFrame from "@/module/editor/components/EditorCanvasFrame.vue";
import EditorTopToolbar from "@/module/editor/components/EditorTopToolbar.vue";
import { useEditorWorkspacePage } from "@/module/editor/service/use-editor-workspace-page";
const {
errorMessage,
previewOperations,
canvasCandidates,
selectedPreviewSummary,
canvasFeedbackSummary,
selectedSelector,
selectedElementMeta,
canvasBridgeCommand,
baseUrl,
deviceMode,
setDeviceMode,
insertBlockType,
insertBlockTypeOptions,
toggleSelectedBlockVisibility,
insertBlockNearSelection,
duplicateSelectedBlock,
deleteSelectedBlock,
canUndo,
canRedo,
undoHistory,
redoHistory,
selectCanvasCandidate,
loadDemoPage,
handleCanvasReady,
handleCanvasHotspotAction,
handleInlineAttributeChange,
handleInlineStyleChange,
handleCanvasHover,
handleInlineTextChange,
keepCurrentSelection,
handleCanvasOutbound,
goBack,
} = useEditorWorkspacePage();
function onKeyDown(e) {
const tag = document.activeElement?.tagName?.toLowerCase()
if (tag === "input" || tag === "textarea" || tag === "select") return
if (document.activeElement?.isContentEditable) return
const mod = e.ctrlKey || e.metaKey
if (!mod) return
if (e.key === "z" && !e.shiftKey) {
e.preventDefault()
if (canUndo.value) undoHistory()
return
}
if ((e.key === "y") || (e.key === "z" && e.shiftKey)) {
e.preventDefault()
if (canRedo.value) redoHistory()
}
}
onMounted(() => window.addEventListener("keydown", onKeyDown))
onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown))
</script>
<style scoped>
.fullscreen-editor {
min-height: 100vh;
background: #f6f8fb;
}
.fullscreen-editor__stage {
display: grid;
min-height: calc(100vh - 72px);
}
.fullscreen-editor__canvas-shell :deep(.editor-canvas-frame) {
min-height: calc(100vh - 72px);
overflow: hidden;
border-radius: 0;
border: 0;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,665 @@
<template>
<el-drawer
:model-value="modelValue"
:title="isEdit ? `編輯實驗 — ${props.experiment?.name || ''}` : '新增實驗'"
size="680px"
direction="rtl"
:close-on-click-modal="false"
:before-close="handleClose"
@closed="resetForm"
>
<div class="form-body">
<!-- 基礎設定 -->
<div class="section">
<p class="section__title">基礎設定</p>
<div v-if="!isEdit" class="field">
<label class="field__label">所屬網站 <span class="field__required">*</span></label>
<el-select
v-model="form.site_id"
placeholder="選擇網站"
filterable
style="width: 100%"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name"
:value="site.id"
/>
</el-select>
<p v-if="validErrors.site_id" class="field__error">{{ validErrors.site_id }}</p>
</div>
<div class="field">
<label class="field__label">實驗名稱 <span class="field__required">*</span></label>
<el-input v-model="form.name" placeholder="例:首頁主視覺 A/B 測試" />
<p v-if="validErrors.name" class="field__error">{{ validErrors.name }}</p>
</div>
<div class="field">
<label class="field__label">狀態</label>
<div class="status-pills">
<button
v-for="opt in statusOptions"
:key="opt.value"
class="status-pill"
:class="{ 'status-pill--active': form.status === opt.value, [`status-pill--${opt.value}`]: true }"
@click="form.status = opt.value"
type="button"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label">開始時間</label>
<el-date-picker
v-model="form.start_at"
type="datetime"
placeholder="選擇開始時間"
style="width: 100%"
/>
</div>
<div class="field">
<label class="field__label">結束時間</label>
<el-date-picker
v-model="form.end_at"
type="datetime"
placeholder="選擇結束時間"
style="width: 100%"
/>
</div>
</div>
</div>
<!-- 啟用條件 -->
<div class="section">
<p class="section__title">啟用條件</p>
<p class="section__desc">設定這個實驗要套用在哪些頁面網址實驗只會在符合條件的頁面上執行</p>
<div class="field">
<label class="field__label">實驗頁面網址</label>
<el-input
v-model="form.base_url"
placeholder="例https://www.example.com/products/page"
clearable
/>
<p class="field__hint">這個網址也會作為視覺編輯器的預設載入頁面</p>
</div>
<!-- URL rules -->
<div class="field">
<label class="field__label">網址規則</label>
<div class="rules-box">
<template v-if="form.url_rules.length === 0">
<p class="rules-empty">尚未設定規則 實驗將只依據上方網址判斷或套用到所有頁面</p>
</template>
<template v-else>
<div
v-for="(rule, idx) in form.url_rules"
:key="rule._id"
class="rule-row-wrap"
>
<div class="rule-connector">
<span class="rule-connector__badge">{{ idx === 0 ? '當' : '且' }}</span>
</div>
<div class="rule-row">
<el-select v-model="rule.operator" style="width: 160px; flex-shrink: 0">
<el-option
v-for="op in urlRuleOperators"
:key="op.value"
:label="op.label"
:value="op.value"
/>
</el-select>
<el-input
v-model="rule.value"
placeholder="輸入網址或關鍵字"
style="flex: 1"
clearable
/>
<button class="rule-delete" @click="removeRule(idx)" type="button" title="刪除此規則">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>
</template>
<button class="add-rule-btn" @click="addRule" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
新增網址規則
</button>
</div>
</div>
<!-- URL tester -->
<div class="field">
<label class="field__label">規則測試</label>
<div class="url-tester">
<el-input
v-model="testUrl"
placeholder="輸入網址來測試規則是否符合"
clearable
style="flex: 1"
/>
<el-button @click="runUrlTest" type="button">檢查</el-button>
</div>
<div v-if="testResult !== null" class="test-result" :class="testResult ? 'test-result--pass' : 'test-result--fail'">
{{ testResult ? ' 符合規則實驗將套用到此網址' : ' 不符合規則實驗不會套用到此網址' }}
</div>
</div>
</div>
<!-- 進階設定 -->
<div class="section">
<p class="section__title">進階設定</p>
<div class="field">
<label class="field__label">實驗裝置限制</label>
<p class="field__hint">選擇要讓哪些裝置的訪客參與這個實驗不選則代表全裝置</p>
<div class="device-pills">
<button
v-for="d in deviceOptions"
:key="d.value"
class="device-pill"
:class="{ 'device-pill--active': form.device_targets.includes(d.value) }"
@click="toggleDevice(d.value)"
type="button"
>
<svg v-if="form.device_targets.includes(d.value)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
{{ d.label }}
</button>
</div>
</div>
<div class="field">
<label class="checkbox-row">
<input type="checkbox" v-model="form.hide_hashtag" class="checkbox-input" />
<span class="checkbox-label">隱藏網址片段hashtag</span>
</label>
<p class="field__hint">啟用後當訪客進入實驗頁面時系統會自動移除網址的 # 片段</p>
</div>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">
{{ isEdit ? "儲存變更" : "建立實驗" }}
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup>
import { computed, ref, watch } from "vue"
import { ElMessage } from "element-plus"
import fastapiClient from "@/services/api/fastapi-client"
import experimentApi from "@/module/experiment/service/experiment-api"
const props = defineProps({
modelValue: { type: Boolean, default: false },
experiment: { type: Object, default: null },
})
const emit = defineEmits(["update:modelValue", "saved"])
const isEdit = computed(() => Boolean(props.experiment?.id))
const saving = ref(false)
const sites = ref([])
const validErrors = ref({})
const testUrl = ref("")
const testResult = ref(null)
const statusOptions = [
{ value: "draft", label: "草稿" },
{ value: "running", label: "進行中" },
{ value: "paused", label: "暫停" },
{ value: "ended", label: "已結束" },
]
const urlRuleOperators = [
{ value: "contains", label: "完整網址包含" },
{ value: "equals", label: "完整網址等於" },
{ value: "starts_with", label: "網址開頭為" },
{ value: "regex", label: "符合正規表達式" },
]
const deviceOptions = [
{ value: "mobile", label: "手機" },
{ value: "tablet", label: "平板" },
{ value: "desktop", label: "桌機" },
]
const defaultForm = () => ({
site_id: "",
name: "",
status: "draft",
start_at: null,
end_at: null,
base_url: "",
url_rules: [],
device_targets: [],
hide_hashtag: false,
})
const form = ref(defaultForm())
const addRule = () => {
form.value.url_rules.push({ _id: Date.now(), operator: "contains", value: "" })
}
const removeRule = (idx) => {
form.value.url_rules.splice(idx, 1)
testResult.value = null
}
const toggleDevice = (value) => {
const idx = form.value.device_targets.indexOf(value)
if (idx === -1) form.value.device_targets.push(value)
else form.value.device_targets.splice(idx, 1)
}
const runUrlTest = () => {
if (!testUrl.value.trim()) return
if (form.value.url_rules.length === 0) {
testResult.value = true
return
}
testResult.value = form.value.url_rules.every(rule => {
const url = testUrl.value
const val = rule.value
switch (rule.operator) {
case "contains": return url.includes(val)
case "equals": return url === val
case "starts_with": return url.startsWith(val)
case "regex": try { return new RegExp(val).test(url) } catch { return false }
default: return false
}
})
}
const loadSites = async () => {
try {
const response = await fastapiClient.request("/api/admin/sites")
sites.value = response.items || []
} catch {
sites.value = []
}
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
if (!isEdit.value) loadSites()
if (props.experiment) {
const tc = props.experiment.targetingConfig || props.experiment.targeting_config || {}
form.value = {
site_id: props.experiment.siteId || props.experiment.site_id || "",
name: props.experiment.name || "",
status: props.experiment.status || "draft",
start_at: props.experiment.startAt || props.experiment.start_at || null,
end_at: props.experiment.endAt || props.experiment.end_at || null,
base_url: tc.base_url || "",
url_rules: (tc.url_rules || []).map((r, i) => ({ ...r, _id: i })),
device_targets: tc.device_targets || [],
hide_hashtag: tc.hide_hashtag || false,
}
} else {
form.value = defaultForm()
}
validErrors.value = {}
testResult.value = null
}
)
const resetForm = () => {
form.value = defaultForm()
validErrors.value = {}
testResult.value = null
}
const handleClose = () => {
emit("update:modelValue", false)
}
const validate = () => {
const errs = {}
if (!isEdit.value && !form.value.site_id) errs.site_id = "請選擇所屬網站"
if (!form.value.name.trim()) errs.name = "請填入實驗名稱"
validErrors.value = errs
return Object.keys(errs).length === 0
}
const buildTargetingConfig = () => {
const config = {}
if (form.value.base_url.trim()) config.base_url = form.value.base_url.trim()
const cleanRules = form.value.url_rules
.filter(r => r.value.trim())
.map(({ operator, value }) => ({ operator, value }))
if (cleanRules.length > 0) config.url_rules = cleanRules
if (form.value.device_targets.length > 0) config.device_targets = form.value.device_targets
if (form.value.hide_hashtag) config.hide_hashtag = true
return Object.keys(config).length > 0 ? config : null
}
const submit = async () => {
if (!validate()) return
saving.value = true
try {
const targeting_config = buildTargetingConfig()
const payload = {
name: form.value.name.trim(),
module_type: "visual",
status: form.value.status,
start_at: form.value.start_at || null,
end_at: form.value.end_at || null,
targeting_config,
}
let result
if (isEdit.value) {
result = await experimentApi.updateExperiment(props.experiment.id, payload)
} else {
result = await experimentApi.createExperiment({
...payload,
site_id: form.value.site_id,
})
}
ElMessage.success(isEdit.value ? "實驗已更新" : "實驗已建立")
emit("update:modelValue", false)
emit("saved", result)
} catch (error) {
ElMessage.error(error?.message || "操作失敗,請稍後再試")
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 0;
padding-bottom: 24px;
}
/* Section */
.section {
padding: 24px 0;
border-bottom: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 18px;
}
.section:last-child {
border-bottom: none;
}
.section__title {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #2563eb;
}
.section__desc {
margin: -10px 0 0;
font-size: 13px;
color: #64748b;
line-height: 1.6;
}
/* Fields */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field__label {
font-size: 13px;
font-weight: 600;
color: #374151;
}
.field__required {
color: #e11d48;
}
.field__hint {
margin: 0;
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
}
.field__error {
margin: 0;
font-size: 12px;
color: #e11d48;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Status pills */
.status-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.status-pill {
padding: 6px 16px;
border-radius: 20px;
border: 1.5px solid #e2e8f0;
background: #f8fafc;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.12s;
}
.status-pill--active.status-pill--draft { background: #fef9c3; border-color: #fde047; color: #854d0e; }
.status-pill--active.status-pill--running { background: #dcfce7; border-color: #86efac; color: #15803d; }
.status-pill--active.status-pill--paused { background: #e0f2fe; border-color: #7dd3fc; color: #075985; }
.status-pill--active.status-pill--ended { background: #ffe4e6; border-color: #fca5a5; color: #9f1239; }
/* URL rules */
.rules-box {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
background: #fafafa;
display: flex;
flex-direction: column;
gap: 6px;
}
.rules-empty {
font-size: 13px;
color: #94a3b8;
margin: 0 0 8px;
text-align: center;
padding: 8px 0;
}
.rule-row-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.rule-connector {
display: flex;
align-items: center;
gap: 8px;
padding-left: 2px;
}
.rule-connector__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 3px 8px;
border-radius: 6px;
background: #2563eb;
color: #fff;
font-size: 11px;
font-weight: 700;
}
.rule-row {
display: flex;
align-items: center;
gap: 8px;
}
.rule-delete {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #94a3b8;
cursor: pointer;
flex-shrink: 0;
transition: color 0.1s, border-color 0.1s;
}
.rule-delete:hover {
color: #e11d48;
border-color: #fca5a5;
}
.add-rule-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: transparent;
color: #2563eb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
align-self: flex-start;
margin-top: 4px;
}
.add-rule-btn:hover {
background: #eff6ff;
}
/* URL tester */
.url-tester {
display: flex;
gap: 8px;
}
.test-result {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.test-result--pass {
background: #dcfce7;
color: #15803d;
}
.test-result--fail {
background: #ffe4e6;
color: #9f1239;
}
/* Device pills */
.device-pills {
display: flex;
gap: 8px;
}
.device-pill {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border: 1.5px solid #e2e8f0;
border-radius: 20px;
background: #f8fafc;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.12s;
}
.device-pill--active {
background: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
/* Checkbox */
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-input {
width: 16px;
height: 16px;
accent-color: #2563eb;
cursor: pointer;
}
.checkbox-label {
font-size: 13.5px;
font-weight: 500;
color: #374151;
}
/* Footer */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #e2e8f0;
}
</style>

View File

@@ -0,0 +1,93 @@
export const statusTypeMap = {
draft: "warning",
running: "success",
paused: "info",
ended: "danger",
}
export const statusLabelMap = {
draft: "草稿",
running: "進行中",
paused: "暫停",
ended: "已結束",
}
export const moduleTypeLabelMap = {
visual: "視覺編輯",
code: "代碼注入",
redirect: "重定向",
}
export const formatDateTime = (value) => {
if (!value) {
return "-"
}
try {
return new Date(value).toLocaleString("zh-TW")
} catch (error) {
return value
}
}
export const mapExperimentListItem = (item = {}) => ({
id: item.id || "",
name: item.name || "未命名實驗",
experimentKey: item.experiment_key || "",
moduleType: item.module_type || "-",
moduleTypeLabel: moduleTypeLabelMap[item.module_type] || item.module_type || "-",
status: item.status || "draft",
statusLabel: statusLabelMap[item.status] || item.status || "-",
startAt: item.start_at || null,
endAt: item.end_at || null,
startAtLabel: formatDateTime(item.start_at),
endAtLabel: formatDateTime(item.end_at),
})
export const mapExperimentDetail = (item = {}) => ({
id: item.id || "",
siteId: item.site_id || "",
name: item.name || "未命名 Experiment",
experimentKey: item.experiment_key || "",
moduleType: item.module_type || "-",
status: item.status || "draft",
startAt: item.start_at || null,
endAt: item.end_at || null,
startAtLabel: formatDateTime(item.start_at),
endAtLabel: formatDateTime(item.end_at),
targetingConfig: item.targeting_config || {},
})
export const resolveExperimentBaseUrl = (experiment = null) => {
const targetingConfig = experiment?.targetingConfig || {}
return (
targetingConfig.base_url ||
targetingConfig.baseUrl ||
targetingConfig.url ||
targetingConfig.page_url ||
""
)
}
export const summarizeExperiments = (items = []) => ({
total: items.length,
running: items.filter((item) => item.status === "running").length,
draft: items.filter((item) => item.status === "draft").length,
ended: items.filter((item) => ["ended", "paused"].includes(item.status)).length,
})
export const mapVariantItem = (item = {}) => ({
id: item.id || "",
variantKey: item.variant_key || "",
name: item.name || "未命名 Variant",
trafficWeight: item.traffic_weight ?? 0,
})
export const mapReleaseItem = (item = {}) => ({
id: item.id || "",
versionNo: item.version_no ?? 0,
releaseVersion: item.version_no != null ? `v${item.version_no}` : "-",
status: item.status || "draft",
publishedAt: item.published_at || null,
publishedAtLabel: formatDateTime(item.published_at),
})

View File

@@ -0,0 +1,44 @@
import fastapiClient from "@/services/api/fastapi-client";
const listExperiments = async () => {
const response = await fastapiClient.request("/api/admin/experiments");
return response.items || [];
};
const getExperiment = async (experimentId) => {
return await fastapiClient.request(`/api/admin/experiments/${experimentId}`);
};
const createExperiment = async (data) => {
return await fastapiClient.request("/api/admin/experiments", {
method: "POST",
body: JSON.stringify(data),
});
};
const updateExperiment = async (experimentId, data) => {
return await fastapiClient.request(`/api/admin/experiments/${experimentId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
};
const listGoals = async ({ siteId } = {}) => {
const query = siteId ? `?site_id=${encodeURIComponent(siteId)}` : "";
const response = await fastapiClient.request(`/api/admin/goals${query}`);
return response.items || [];
};
const listActivity = async (experimentId) => {
const response = await fastapiClient.request(`/api/admin/experiments/${experimentId}/activity`);
return response.items || [];
};
export default {
listExperiments,
getExperiment,
createExperiment,
updateExperiment,
listGoals,
listActivity,
};

View File

@@ -0,0 +1,130 @@
import { computed, onMounted, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import experimentApi from "@/module/experiment/service/experiment-api"
import releaseApi from "@/module/release/service/release-api"
import variantApi from "@/module/variant/service/variant-api"
import {
mapExperimentDetail,
mapReleaseItem,
mapVariantItem,
resolveExperimentBaseUrl,
} from "@/module/experiment/model/experiment-view-model"
export function useExperimentDetailPage() {
const route = useRoute()
const router = useRouter()
const experiment = ref(null)
const variants = ref([])
const releases = ref([])
const goals = ref([])
const activityLog = ref([])
const errorMessage = ref("")
const experimentId = computed(() => String(route.params.experimentId || ""))
const recommendedBaseUrl = computed(() =>
resolveExperimentBaseUrl(experiment.value)
)
const latestRelease = computed(() => releases.value[0] || null)
const loadPage = async () => {
errorMessage.value = ""
try {
const [experimentItem, variantItems, releaseItems] = await Promise.all([
experimentApi.getExperiment(experimentId.value),
variantApi.listVariants({ experimentId: experimentId.value }),
releaseApi.listReleases({ experimentId: experimentId.value }),
])
const mapped = mapExperimentDetail(experimentItem)
experiment.value = mapped
variants.value = variantItems.map(mapVariantItem)
releases.value = releaseItems.map(mapReleaseItem)
if (mapped.siteId) {
try {
goals.value = await experimentApi.listGoals({ siteId: mapped.siteId })
} catch {
goals.value = []
}
}
try {
activityLog.value = await experimentApi.listActivity(experimentId.value)
} catch {
activityLog.value = []
}
} catch (error) {
errorMessage.value = error?.message || "無法載入實驗詳情。"
experiment.value = null
variants.value = []
releases.value = []
goals.value = []
activityLog.value = []
}
}
const saveExperimentSettings = async (payload) => {
await experimentApi.updateExperiment(experimentId.value, payload)
await loadPage()
}
const buildRelease = async () => {
await releaseApi.buildRelease(experimentId.value)
await loadPage()
}
const publishRelease = async (releaseId) => {
await releaseApi.publishRelease(releaseId)
await loadPage()
}
const rollbackRelease = async (releaseId) => {
await releaseApi.rollbackRelease(releaseId)
await loadPage()
}
const archiveRelease = async (releaseId) => {
await releaseApi.archiveRelease(releaseId)
await loadPage()
}
const goBack = () => {
router.push({ name: "experiments" })
}
const openEditor = (variantId) => {
router.push({
name: "editor-variant",
params: { variantId },
query: recommendedBaseUrl.value ? { base_url: recommendedBaseUrl.value } : undefined,
})
}
onMounted(async () => {
await loadPage()
})
return {
route,
experimentId,
experiment,
variants,
releases,
goals,
activityLog,
errorMessage,
recommendedBaseUrl,
latestRelease,
loadPage,
saveExperimentSettings,
buildRelease,
publishRelease,
rollbackRelease,
archiveRelease,
goBack,
openEditor,
}
}

View File

@@ -0,0 +1,54 @@
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import experimentApi from "@/module/experiment/service/experiment-api"
import {
mapExperimentListItem,
statusTypeMap,
summarizeExperiments,
} from "@/module/experiment/model/experiment-view-model"
export function useExperimentListPage() {
const router = useRouter()
const experiments = ref([])
const loading = ref(false)
const errorMessage = ref("")
const summary = computed(() => summarizeExperiments(experiments.value))
const loadExperiments = async () => {
loading.value = true
errorMessage.value = ""
try {
const items = await experimentApi.listExperiments()
experiments.value = items.map(mapExperimentListItem)
} catch (error) {
experiments.value = []
errorMessage.value = error?.message || "無法載入 experiment 列表。"
} finally {
loading.value = false
}
}
const openExperiment = (experimentId) => {
router.push({
name: "experiment-detail",
params: { experimentId },
})
}
onMounted(async () => {
await loadExperiments()
})
return {
experiments,
loading,
errorMessage,
summary,
statusTypeMap,
loadExperiments,
openExperiment,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
<template>
<AdminPageShell
eyebrow="銷售實驗"
title="實驗管理"
description="建立並管理你的 A/B 測試實驗,每個實驗可設定多組版本,透過視覺編輯器調整頁面內容。"
>
<template #actions>
<el-button @click="loadExperiments">重新整理</el-button>
<el-button type="primary" @click="openCreateDialog">新增實驗</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="summary.total" hint="目前已建立的實驗數量" />
<AdminMetricCard label="進行中" :value="summary.running" tone="success" hint="目前正在運行中的實驗" />
<AdminMetricCard label="草稿" :value="summary.draft" tone="warning" hint="尚未上線的草稿實驗" />
<AdminMetricCard label="已結束" :value="summary.ended" tone="danger" hint="已結束或暫停的實驗" />
</div>
</template>
<div class="card">
<!-- Tab filter -->
<div class="tab-bar">
<button
v-for="tab in tabs"
:key="tab.value"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
<span class="tab-btn__count">{{ tabCounts[tab.value] }}</span>
</button>
</div>
<el-table :data="filteredExperiments" v-loading="loading" row-key="id">
<el-table-column label="實驗名稱" min-width="240">
<template #default="{ row }">
<span class="exp-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="狀態" width="110">
<template #default="{ row }">
<span class="status-badge" :class="`status-badge--${row.status}`">
{{ row.statusLabel }}
</span>
</template>
</el-table-column>
<el-table-column label="開始時間" min-width="160">
<template #default="{ row }">
<span class="time-label">{{ row.startAtLabel }}</span>
</template>
</el-table-column>
<el-table-column label="結束時間" min-width="160">
<template #default="{ row }">
<span class="time-label">{{ row.endAtLabel }}</span>
</template>
</el-table-column>
<el-table-column label="" width="160" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button type="primary" size="small" @click="openExperiment(row.id)">查看詳情</el-button>
<el-button size="small" @click="openEditDialog(row)">編輯</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="filteredExperiments.length === 0 && !loading" class="empty-state">
{{ activeTab === 'all' ? '尚未建立任何實驗' : `目前沒有${tabs.find(t => t.value === activeTab)?.label}的實驗` }}
</div>
</div>
<ExperimentFormDialog
v-model="formDialogOpen"
:experiment="editingExperiment"
@saved="loadExperiments"
/>
</AdminPageShell>
</template>
<script setup>
import { computed, ref } from "vue"
import AdminMetricCard from "@/components/AdminMetricCard.vue";
import AdminPageShell from "@/components/AdminPageShell.vue";
import ExperimentFormDialog from "@/module/experiment/components/ExperimentFormDialog.vue";
import { useExperimentListPage } from "@/module/experiment/service/use-experiment-list-page";
const {
experiments,
loading,
errorMessage,
summary,
loadExperiments,
openExperiment,
} = useExperimentListPage();
const formDialogOpen = ref(false)
const editingExperiment = ref(null)
const activeTab = ref("all")
const tabs = [
{ value: "all", label: "全部" },
{ value: "running", label: "進行中" },
{ value: "draft", label: "草稿" },
{ value: "ended", label: "已結束" },
]
const tabCounts = computed(() => ({
all: experiments.value.length,
running: experiments.value.filter(e => e.status === "running").length,
draft: experiments.value.filter(e => e.status === "draft").length,
ended: experiments.value.filter(e => ["ended", "paused"].includes(e.status)).length,
}))
const filteredExperiments = computed(() => {
if (activeTab.value === "all") return experiments.value
if (activeTab.value === "ended") return experiments.value.filter(e => ["ended", "paused"].includes(e.status))
return experiments.value.filter(e => e.status === activeTab.value)
})
const openCreateDialog = () => {
editingExperiment.value = null
formDialogOpen.value = true
}
const openEditDialog = (row) => {
editingExperiment.value = row
formDialogOpen.value = true
}
</script>
<style scoped>
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
}
/* Tab bar */
.tab-bar {
display: flex;
gap: 2px;
padding: 16px 16px 0;
border-bottom: 1px solid #f1f5f9;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
background: transparent;
border-radius: 8px 8px 0 0;
font-size: 13.5px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: background 0.1s, color 0.1s;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-btn:hover {
color: #0f172a;
background: #f8fafc;
}
.tab-btn--active {
color: #2563eb;
border-bottom-color: #2563eb;
font-weight: 600;
}
.tab-btn__count {
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 20px;
background: #f1f5f9;
color: #64748b;
min-width: 18px;
text-align: center;
}
.tab-btn--active .tab-btn__count {
background: #dbeafe;
color: #2563eb;
}
/* Table cells */
.exp-name {
font-size: 14px;
font-weight: 500;
color: #0f172a;
}
.time-label {
font-size: 13px;
color: #64748b;
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-badge--draft { background: #fef9c3; color: #854d0e; }
.status-badge--running { background: #dcfce7; color: #15803d; }
.status-badge--paused { background: #e0f2fe; color: #075985; }
.status-badge--ended { background: #ffe4e6; color: #9f1239; }
/* Row actions */
.row-actions {
display: flex;
gap: 6px;
}
/* Empty state */
.empty-state {
padding: 40px;
text-align: center;
font-size: 14px;
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<el-drawer
:model-value="modelValue"
title="搭配優惠代碼功能說明"
direction="ltr"
size="50%"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #default>
<article>
<el-row class="tw-space-y-5">
<el-col>
<el-row class="tw-items-center">
<el-col :span="3" class="tw-font-light">優惠代碼</el-col>
<el-col :span="21">
<el-input
:input-style="{ fontSize: 'medium' }"
size="large"
:model-value="couponCode"
@update:model-value="$emit('update:couponCode', $event)"
/>
</el-col>
</el-row>
</el-col>
<el-col>
<el-row class="tw-items-center">
<el-col :span="3" class="tw-font-light">推薦代碼</el-col>
<el-col :span="21">
<el-input
:input-style="{ fontSize: 'medium' }"
size="large"
:model-value="affiliateCode"
@update:model-value="$emit('update:affiliateCode', $event)"
/>
</el-col>
</el-row>
</el-col>
<el-col>
<el-radio-group
:model-value="couponType"
@update:model-value="$emit('update:couponType', $event)"
>
<el-row>
<el-col :span="8" class="tw-pb-3">
<el-radio label="優惠代碼" size="large" border>優惠代碼</el-radio>
</el-col>
<el-col :span="8" class="tw-pb-3">
<el-radio label="推薦代碼" size="large" border>推薦代碼</el-radio>
</el-col>
<el-col :span="8" class="tw-pb-3">
<el-radio label="優惠代碼+推薦代碼" size="large" border>優惠代碼+推薦代碼</el-radio>
</el-col>
<el-col :span="8" class="tw-pb-3">
<el-radio label="行銷圖卡+優惠代碼" size="large" border>行銷圖卡+優惠代碼</el-radio>
</el-col>
<el-col :span="8" class="tw-pb-3">
<el-radio label="行銷圖卡+推薦代碼" size="large" border>行銷圖卡+推薦代碼</el-radio>
</el-col>
<el-col :span="8" class="tw-pb-3">
<el-radio label="行銷圖卡+優惠代碼+推薦代碼" size="large" border
>行銷圖卡+優惠代碼+推薦代碼</el-radio
>
</el-col>
</el-row>
</el-radio-group>
</el-col>
<el-col>
<p>網址</p>
<a class="tw-text-blue-600" :href="cardCodeUrl">{{ cardCodeUrl }}</a>
</el-col>
</el-row>
</article>
</template>
</el-drawer>
</template>
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
couponType: {
type: String,
default: "",
},
couponCode: {
type: String,
default: "",
},
affiliateCode: {
type: String,
default: "",
},
cardCodeUrl: {
type: String,
default: "",
},
})
defineEmits([
"update:modelValue",
"update:couponType",
"update:couponCode",
"update:affiliateCode",
])
</script>

View File

@@ -0,0 +1,65 @@
<template>
<el-drawer
:model-value="modelValue"
title="選擇 / 上傳圖片"
direction="ltr"
size="50%"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #default>
<section class="-tw-my-5 tw-space-y-7 tw-h-full">
<el-upload drag :http-request="onImageUpload" :show-file-list="false">
<el-icon class="el-icon--upload">
<upload-filled />
</el-icon>
<div class="el-upload__text">拖移檔案至此 <em>點擊上傳</em></div>
</el-upload>
<el-card style="height: 73%" :body-style="{ height: '100%' }">
<div class="tw-grid tw-grid-cols-4 tw-gap-4 tw-h-full tw-overflow-auto">
<div
v-for="item in images"
:key="item.url"
style="height: 150px"
>
<el-image
:src="item.url"
fit="fill"
:class="[
normalizeImageUrl(item.url) === selectedImageUrl ? 'tw-border-4 tw-border-blue-500' : '',
'tw-h-full tw-w-full tw-rounded-md',
]"
loading="lazy"
@click="$emit('select', normalizeImageUrl(item.url))"
/>
</div>
</div>
</el-card>
</section>
</template>
</el-drawer>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
images: {
type: Array,
default: () => [],
},
selectedImageUrl: {
type: String,
default: "",
},
onImageUpload: {
type: Function,
required: true,
},
})
defineEmits(["update:modelValue", "select"])
const normalizeImageUrl = (url) => url.replace("adapi", "pass")
</script>

View File

@@ -0,0 +1,153 @@
<template>
<el-main class="tw-h-full">
<article class="tw-h-full tw-w-full tw-grid tw-justify-items-center">
<div class="tw-h-full tw-space-y-8" style="min-width: 375px; max-width: 375px">
<section class="tw-space-x-3 tw-flex tw-justify-between">
<span class="tw-font-bold tw-text-2xl">樣式</span>
<el-select
:model-value="cardStyle"
placeholder="Select"
size="large"
style="width: 240px"
@update:model-value="$emit('update:cardStyle', $event)"
>
<el-option key="1" label="1" value="1" />
<el-option key="2" label="2" value="2" />
</el-select>
</section>
<section class="preview-card-container tw-space-y-3">
<p class="tw-font-bold tw-text-2xl">原始圖卡</p>
<div class="card-container">
<ose_card v-if="data.card_style == 1" :model-value="data" />
<ose_card2 v-else-if="data.card_style == 2" :model-value="data" />
</div>
</section>
<section class="preview-hint-container tw-space-y-3">
<p class="tw-font-bold tw-text-2xl">縮小圖卡</p>
<div class="tw-space-y-5">
<div class="tw-space-y-1">
<p class="tw-font-normal tw-text-base">
<el-icon size="15" :color="'#2f91ee'">
<Iphone />
</el-icon>
手機版
</p>
<div class="mini-card-container">
<ose_mini_card
:timer="data.timer"
:minicard_transparency="data.minicard_phone_transparency"
:minicard_bg="data.minicard_phone_bg"
:minicard_word="data.minicard_phone_word"
:minicard_word_color="data.minicard_phone_word_color"
:minicard_word_align="data.minicard_phone_word_align"
:minicard_timer_start_word="data.minicard_phone_timer_start_word"
:minicard_timer_start_word_color="data.minicard_phone_timer_start_word_color"
:minicard_timer_end_word="data.minicard_phone_timer_end_word"
:minicard_timer_end_word_color="data.minicard_phone_timer_end_word_color"
/>
</div>
</div>
<div class="tw-space-y-1">
<p class="tw-font-normal tw-text-base">
<el-icon size="15" :color="'#2f91ee'">
<Monitor />
</el-icon>
電腦版
</p>
<div class="mini-card-container">
<ose_mini_card
:timer="data.timer"
:minicard_transparency="data.minicard_computer_transparency"
:minicard_bg="data.minicard_computer_bg"
:minicard_word="data.minicard_computer_word"
:minicard_word_color="data.minicard_computer_word_color"
:minicard_word_align="data.minicard_computer_word_align"
:minicard_timer_start_word="data.minicard_computer_timer_start_word"
:minicard_timer_start_word_color="data.minicard_computer_timer_start_word_color"
:minicard_timer_end_word="data.minicard_computer_timer_end_word"
:minicard_timer_end_word_color="data.minicard_computer_timer_end_word_color"
/>
</div>
</div>
</div>
</section>
<section class="preview-dynamic-container">
<p class="tw-font-bold tw-text-2xl tw-pb-3">實際效果呈現</p>
<div>
<el-row>
<el-col :span="12" class="tw-space-x-3">
<span>
<el-icon size="15" :color="'#2f91ee'">
<Iphone />
</el-icon>
<span class="tw-text-base tw-font-normal"> 手機版</span>
</span>
<el-switch
:model-value="dynamicPhoneVisible"
inline-prompt
active-text=""
inactive-text=""
:active-value="true"
:inactive-value="false"
@update:model-value="$emit('update:dynamicPhoneVisible', $event)"
/>
</el-col>
<el-col :span="12" class="tw-space-x-3">
<span>
<el-icon size="15" :color="'#2f91ee'">
<Monitor />
</el-icon>
<span class="tw-text-base tw-font-normal"> 電腦版</span>
</span>
<el-switch
:model-value="dynamicComputerVisible"
inline-prompt
active-text=""
inactive-text=""
:active-value="true"
:inactive-value="false"
@update:model-value="$emit('update:dynamicComputerVisible', $event)"
/>
</el-col>
</el-row>
</div>
</section>
</div>
</article>
</el-main>
</template>
<script setup>
import ose_card from "@/module/ose-card/components/card.vue"
import ose_card2 from "@/module/ose-card/components/card2.vue"
import ose_mini_card from "@/module/ose-card/components/mini-card.vue"
defineProps({
data: {
type: Object,
required: true,
},
cardStyle: {
type: [String, Number],
default: 1,
},
dynamicPhoneVisible: {
type: Boolean,
default: false,
},
dynamicComputerVisible: {
type: Boolean,
default: false,
},
})
defineEmits([
"update:cardStyle",
"update:dynamicPhoneVisible",
"update:dynamicComputerVisible",
])
</script>

View File

@@ -0,0 +1,20 @@
<template>
<section class="ose-card-table-actions">
<el-button size="default" @click="$emit('preview')">預覽</el-button>
<el-button size="default" @click="$emit('edit')">編輯</el-button>
<el-button size="default" @click="$emit('copy')">複製</el-button>
<el-button size="default" @click="$emit('delete')">刪除</el-button>
</section>
</template>
<script setup>
defineEmits(["preview", "edit", "copy", "delete"]);
</script>
<style scoped>
.ose-card-table-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="ose-card"
style="overflow: hidden; display: flex; flex-direction: column; width: 100%; min-width: 375px; box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;">
<article :class="['count-down-timer']" :hidden="modelValue.timer?false:true" style="">
<div class="ose-time_container"
:style="`text-align: center; background-color: ${modelValue.timer_bg}; padding-top: 3px; padding-bottom: 3px;
display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: 18px;`">
<section class="ose-t_description" :style="`margin-right: 0.3em; color: ${modelValue.timer_start_word_color};`">
{{ modelValue.timer_start_word }}
</section>
<section class="ose-t_days"
:style="`text-align: center; min-width: 24px; color: ${modelValue.timer_day_number_color};`">00
</section>
<section class="ose-t_days_unit" :style="`color: ${modelValue.timer_day_word_color};`">
{{modelValue.timer_day_word}}
</section>
<section class="ose-t_hours"
:style="`text-align: center; width: 24px; color: ${modelValue.timer_hour_number_color};`">00
</section>
<section class="ose-t_hours_unit" :style="`margin-right: 0.3em; color: ${modelValue.timer_hour_word_color};`">
{{modelValue.timer_hour_word}}
</section>
<section class="ose-t_minutes"
:style="`text-align: center; width: 23px; color: ${modelValue.timer_minute_number_color};`">00
</section>
<section class="ose-t_minutes_unit" :style="`margin-right: 0.3em; color: ${modelValue.timer_minute_word_color};`">
{{ modelValue.timer_minute_word }}
</section>
<section class="ose-t_seconds"
:style="`text-align: center; width: 24px; color: ${modelValue.timer_second_number_color};`">00
</section>
<section class="ose-t_seconds_unit" :style="`color: ${modelValue.timer_second_word_color};`">
{{modelValue.timer_second_word}}
</section>
<section class="ose-t_description_end" :style="`margin-left: 0.3em; color: ${modelValue.timer_end_word_color};`">
{{ modelValue.timer_end_word }}
</section>
</div>
</article>
<article class="ose-card-content"
style="display: flex; flex-direction: row; overflow: hidden; max-height: 100px">
<img v-if="!modelValue.card_img_url"
src="@a/ose-logo2.png"
class="ose-card-img"
:style="`min-width: 100px; max-width: 100px;
width: 100px; height: 100px; min-height: 100px; max-height: 100px; background-color:${modelValue.card_img_bg}`">
<img v-if="modelValue.card_img_url"
:src="modelValue.card_img_url"
class="card-img"
:style="`min-width: 100px; max-width: 100px;
width: 100px; height: 100px; min-height: 100px; max-height: 100px; background-color:${modelValue.card_img_bg}`">
<section class="ose-card-text" style="width: 100%;">
<div class="ose-card-header"
:style="`min-height: 1em; background-color: ${modelValue.card_title_bg}; position: relative;`">
<h3
:style="`text-align: ${modelValue.card_title_word_align} ;color: ${modelValue.card_title_word_color}; padding: 3px 15px 3px 12px; font-size: 20px; margin: 0px
; font-weight: bold; line-height: normal;`">
{{ modelValue.card_title_word }}
</h3>
<div class="ose-close-card-btn"
:style="`position: absolute; color: ${modelValue.card_cancel_btn_color}; font-style: normal; right: 12px; top: 4px; cursor: pointer;`">
</div>
</div>
<section class="ose-card-text-content "
:style="` padding-left: 12px; height: 100%; background-color: ${modelValue.card_text_bg}; font-size: 16px;`" v-html="modelValue.card_text_content">
</section>
</section>
</article>
<article class="ose-card-footer"
:style="`min-height: 1em; background-color: ${modelValue.card_footer_bg};`">
<h3
:style="`text-align: ${modelValue.card_footer_word_align}; margin: 0px; font-size: 18px; font-weight: bold;
letter-spacing: 3px; color:${modelValue.card_footer_word_color}; padding: 5px; line-height: normal;`">
{{ modelValue.card_footer_word }}
</h3>
</article>
</div>
</template>
<script setup>
import {watchEffect , ref} from "vue";
const props = defineProps(['modelValue'])
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="ose-card"
style="overflow: hidden; display: flex; flex-direction: column; width: 100%; min-width: 375px; box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;">
<article class="ose-card-content"
style="display: flex; flex-direction: row; overflow: hidden; max-height: 135px;position: relative">
<img v-if="!modelValue.card_img_url"
src="@a/ose-logo2.png"
class="ose-card-img"
:style="`width: 100%; height: auto; background-color:${modelValue.card_img_bg}`">
<img v-if="modelValue.card_img_url"
:src="modelValue.card_img_url"
class="card-img"
:style="`width: 100%; height: auto; background-color:${modelValue.card_img_bg}`">
<div class="ose-close-card-btn"
:style="`position: absolute; color: ${modelValue.card_cancel_btn_color}; font-style: normal; right: 12px; top: 4px; cursor: pointer;`">
</div>
<article :class="['count-down-timer']" :hidden="modelValue.timer?false:true"
:style="`position: absolute;left: 0px; right: ${modelValue.timer_x_axis}px; bottom: ${modelValue.timer_y_axis}px; background-color: ${modelValue.timer_bg};`"
>
<div class="ose-time_container"
:style="`text-align: center; background-color: ${modelValue.timer_bg}; padding-top: 3px; padding-bottom: 3px;
display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: ${modelValue.timer_font_size}px; gap:${modelValue.timer_space}px`"
>
<section class="ose-t_days"
:style="`text-align: center; min-width: 24px; color: ${modelValue.timer_day_number_color};`">00
</section>
<section class="ose-t_days_unit" :style="`color: ${modelValue.timer_day_word_color};`">
{{ modelValue.timer_day_word }}
</section>
<section class="ose-t_hours"
:style="`text-align: center; color: ${modelValue.timer_hour_number_color};`">00
</section>
<section class="ose-t_hours_unit" :style="`color: ${modelValue.timer_hour_word_color};`">
{{ modelValue.timer_hour_word }}
</section>
<section class="ose-t_minutes"
:style="`text-align: center; color: ${modelValue.timer_minute_number_color};`">00
</section>
<section class="ose-t_minutes_unit"
:style="`color: ${modelValue.timer_minute_word_color};`">
{{ modelValue.timer_minute_word }}
</section>
<section class="ose-t_seconds"
:style="`text-align: center; color: ${modelValue.timer_second_number_color};`">00
</section>
<section class="ose-t_seconds_unit" :style="`color: ${modelValue.timer_second_word_color};`">
{{ modelValue.timer_second_word }}
</section>
</div>
</article>
</article>
</div>
</template>
<script setup>
import {watchEffect, ref} from "vue";
const props = defineProps(['modelValue'])
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="ose-mini-card"
:style="`width: 100%; min-height: 1em; background-color: ${minicard_bg};
color: ${minicard_word_color}; text-align: ${minicard_word_align};
cursor: pointer; padding: 5px 0px; font-weight: bold; font-size: 14px; opacity:${minicard_transparency}`">
<div class="ose-mini-card-text-container"> {{ minicard_word }}</div>
<article :class="['count-down-timer']" :hidden="timer?false:true" style="">
<div class="ose-time_container"
style="text-align: center; background-color: transparent; padding-top: 3px; padding-bottom: 3px;
display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: 14px;">
<section class="ose-t_description" :style="`margin-right: 0.3em; color:${minicard_timer_start_word_color};`"> {{minicard_timer_start_word}}
</section>
<section class="ose-t_days"
style="text-align: center; min-width: 24px; color: rgb(255, 255, 255);">00
</section>
<section class="ose-t_days_unit" style="color: rgb(255, 255, 255);"> </section>
<section class="ose-t_hours"
style="text-align: center; width: 24px; color: rgb(255, 255, 255);">00
</section>
<section class="ose-t_hours_unit" style="margin-right: 0.3em; color: rgb(255, 255, 255);"> 小時
</section>
<section class="ose-t_minutes"
style="text-align: center; width: 23px; color: rgb(255, 255, 255);">00
</section>
<section class="ose-t_minutes_unit" style="margin-right: 0.3em; color: rgb(255, 255, 255);">
</section>
<section class="ose-t_seconds"
style="text-align: center; width: 24px; color: rgb(255, 255, 255);">00
</section>
<section class="ose-t_seconds_unit" style="color: rgb(255, 255, 255);"> </section>
<section class="ose-t_description_end" :style="`margin-left: 0.3em; color: ${minicard_timer_end_word_color};`"> {{minicard_timer_end_word}}
</section>
</div>
</article>
</div>
</template>
<script setup>
const props = defineProps([
'minicard_word_color','minicard_bg','minicard_word_align','minicard_word','minicard_transparency',
'minicard_timer_start_word','minicard_timer_start_word_color','minicard_timer_end_word','minicard_timer_end_word_color','timer'
])
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,67 @@
export default [
{
id:"id",
name:"序列",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"100",
}
},
{
id:"card_name",
name:"圖卡名稱",
header_setup:{
sortable:false,
"show-overflow-tooltip":true,
width:"280"
}
},
{
id:"card_code",
name:"圖卡代碼",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"150"
}
},
{
id:"start_date",
name:"開始時間",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"180"
}
},
{
id:"end_date",
name:"結束時間",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"180"
}
},
{
id:"carousel_days",
name:"循環天數",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"110"
}
},
{
id:"origin",
name:"網域",
header_setup:{
sortable:true,
"show-overflow-tooltip":false,
width:"250"
}
},
]

View File

@@ -0,0 +1,102 @@
import {
createItem,
deleteItem,
readFiles,
readFolders,
readItems,
updateFile,
updateItem,
uploadFiles,
} from "@directus/sdk";
import { buildDirectusAssetUrl } from "@/config/env";
export const listCards = async (client, corporateCustomerId) => {
return await client.request(
readItems("marketing_card", {
filter: {
corporate_customer: {
_eq: corporateCustomerId,
},
},
})
);
};
export const deleteCardById = async (client, id) => {
return await client.request(deleteItem("marketing_card", id));
};
export const listCardsByFilter = async (client, filter) => {
return await client.request(
readItems("marketing_card", {
filter,
})
);
};
export const saveCardItem = async (client, options) => {
if (options.mode === "edit") {
return await client.request(
updateItem("marketing_card", options.id, options.item)
);
}
return await client.request(createItem("marketing_card", options.item));
};
export const listCardImages = async (client) => {
const files = await client.request(
readFiles({
filter: {
type: {
_contains: "image",
},
folder: {
name: {
_eq: "OSE Card",
},
},
},
fields: ["id"],
sort: ["-uploaded_on"],
})
);
return files.map((item) => ({
url: buildDirectusAssetUrl(item.id),
}));
};
export const getCardFolderId = async (client) => {
const folders = await client.request(
readFolders({
fields: ["id"],
filter: {
name: "OSE Card",
},
})
);
return folders[0]?.id || null;
};
export const moveCardFileToFolder = async (client, fileId, folderId) => {
return await client.request(
updateFile(fileId, {
folder: folderId,
})
);
};
export const uploadCardImage = async (client, file, folderId) => {
const result = await client.request(uploadFiles(file));
if (folderId) {
await moveCardFileToFolder(client, result.id, folderId);
}
return {
id: result.id,
url: buildDirectusAssetUrl(result.id),
};
};

View File

@@ -0,0 +1,87 @@
const buildUrl = (landingPage) => {
try {
return new URL(landingPage);
} catch (error) {
return null;
}
};
export const buildOseCardPreviewUrl = (landingPage, cardCode) => {
const url = buildUrl(landingPage);
if (!url) {
return "";
}
if (cardCode) {
url.searchParams.set("ose-card", cardCode);
}
return url.toString();
};
export const buildOseCardCouponUrl = (
landingPage,
cardCode,
couponType,
couponCode,
affiliateCode
) => {
const url = buildUrl(landingPage);
if (!url) {
return "";
}
switch (couponType) {
case "優惠代碼":
if (couponCode) {
url.searchParams.set("oc", couponCode);
}
break;
case "推薦代碼":
if (affiliateCode) {
url.searchParams.set("ac", affiliateCode);
}
break;
case "優惠代碼+推薦代碼":
if (couponCode) {
url.searchParams.set("oc", couponCode);
}
if (affiliateCode) {
url.searchParams.set("ac", affiliateCode);
}
break;
case "行銷圖卡+優惠代碼":
if (cardCode) {
url.searchParams.set("mc", cardCode);
}
if (couponCode) {
url.searchParams.set("oc", couponCode);
}
break;
case "行銷圖卡+推薦代碼":
if (cardCode) {
url.searchParams.set("mc", cardCode);
}
if (affiliateCode) {
url.searchParams.set("ac", affiliateCode);
}
break;
case "行銷圖卡+優惠代碼+推薦代碼":
if (cardCode) {
url.searchParams.set("mc", cardCode);
}
if (couponCode) {
url.searchParams.set("oc", couponCode);
}
if (affiliateCode) {
url.searchParams.set("ac", affiliateCode);
}
break;
default:
return "";
}
return url.toString();
};

View File

@@ -0,0 +1,104 @@
function querySelectorAllForEachInnerHtml(documents, value) {
documents.forEach((dom) => {
dom.innerText = value
})
}
function getCountDownUnit(distance) {
var days = Math.floor(distance / (1000 * 60 * 60 * 24));
var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
var seconds = Math.floor((distance % (1000 * 60)) / 1000);
days = days.toString()
if (days.length === 1) {
days = '0' + days
}
hours = hours.toString()
if (hours.length === 1) {
hours = '0' + hours
}
minutes = minutes.toString()
if (minutes.length === 1) {
minutes = '0' + minutes
}
seconds = seconds.toString()
if (seconds.length === 1) {
seconds = '0' + seconds
}
var result = {
t_days: days,
t_hours: hours,
t_minutes: minutes,
t_seconds: seconds
}
return result
}
function getCarouselDateDistance(now,end_at,carousel_days){
var distance_days = Math.ceil((now-end_at) / (1000 * 60 * 60 * 24))
var freq = Math.ceil(distance_days/carousel_days)
var increase_days_times = freq * carousel_days * (1000 * 60 * 60 * 24)
var distance = end_at + increase_days_times - now
return distance
}
function initTimer(start_at, end_at, init,carousel_days) {
var init_now = new Date().getTime();
var init_distance = end_at - init_now;
if ((init_distance < 0 && (carousel_days == 0 || !carousel_days)) || init_now < start_at || init) {
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_days"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_hours"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_minutes"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_seconds"), '00')
return null
}
/*if (init_now < start_at) {
init_distance = end_at - start_at;
}
var init_show_units = getCountDownUnit(init_distance)
var init_units = Object.keys(init_show_units)
init_units.forEach(function(unit){
querySelectorAllForEachInnerHtml(document.querySelectorAll("."+unit),init_show_units[unit])
})
if (init_now < start_at) {
return null
}*/
var timer = setInterval(function () {
var now = new Date().getTime();
var distance = end_at - now;
if (distance < 0 && (carousel_days == 0 || !carousel_days)) {
clearInterval(timer);
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_days"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_hours"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_minutes"), '00')
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-t_seconds"), '00')
return null
}
else if(distance < 0 && carousel_days > 0){
distance = getCarouselDateDistance(now,end_at,carousel_days)
}
var show_units = getCountDownUnit(distance)
//console.log(show_units)
var units = Object.keys(show_units)
units.forEach(function (unit) {
querySelectorAllForEachInnerHtml(document.querySelectorAll(".ose-" + unit), show_units[unit])
})
}, 1000);
return timer
}
export default {
initTimer
}

View File

@@ -0,0 +1,98 @@
import { ref, watch } from "vue"
import timer from "@/module/ose-card/service/timer"
export function useOseCardDynamicPreview({ data, store }) {
const dynamicPhoneVisible = ref(false)
const dynamicComputerVisible = ref(false)
const initializeDynamicCard = (cardContainerClassName, miniCardContainerClassName, isPhone) => {
if (store.state.ose_card_store.timer_id) {
clearInterval(store.state.ose_card_store.timer_id)
}
timer.initTimer(new Date(data.start_date).getTime(), new Date(data.end_date).getTime(), true)
const dynamicPhoneContainer = document.querySelector(".dynamic_phone_container")
const dynamicComputerContainer = document.querySelector(".dynamic_computer_container")
const dynamicCardContainer = document.querySelector(`.${cardContainerClassName}`)
const miniDynamicCardContainer = document.querySelector(`.${miniCardContainerClassName}`)
if (isPhone) {
dynamicCardContainer.style.display = "none"
miniDynamicCardContainer.style.display = "none"
} else {
dynamicCardContainer.style.display = ""
miniDynamicCardContainer.style.display = "none"
}
data.dynamic_phone_container = dynamicPhoneContainer.outerHTML
data.dynamic_computer_container = dynamicComputerContainer.outerHTML
dynamicCardContainer.style.display = ""
miniDynamicCardContainer.style.display = "none"
}
const bindDynamicCardControl = (cardContainerClassName, miniCardContainerClassName) => {
const dynamicCardContainer = document.querySelector(`.${cardContainerClassName}`)
const miniDynamicCardContainer = document.querySelector(`.${miniCardContainerClassName}`)
const closeButton = dynamicCardContainer?.querySelector(".ose-close-card-btn")
closeButton?.addEventListener("click", () => {
dynamicCardContainer.style.display = "none"
miniDynamicCardContainer.style.display = ""
})
miniDynamicCardContainer?.addEventListener("click", () => {
dynamicCardContainer.style.display = ""
miniDynamicCardContainer.style.display = "none"
})
}
const prepareDynamicPreviewForSave = () => {
initializeDynamicCard("dynamic_computer_card_container", "mini_dynamic_computer_card_container")
initializeDynamicCard("dynamic_phone_card_container", "mini_dynamic_phone_card_container", true)
}
watch(() => dynamicComputerVisible.value, () => {
bindDynamicCardControl("dynamic_computer_card_container", "mini_dynamic_computer_card_container")
})
watch(() => dynamicPhoneVisible.value, () => {
bindDynamicCardControl("dynamic_phone_card_container", "mini_dynamic_phone_card_container")
})
watch(
() => [data.start_date, data.end_date, data.carousel_days],
() => {
if (data.start_date && data.end_date) {
if (store.state.ose_card_store.timer_id) {
clearInterval(store.state.ose_card_store.timer_id)
}
store.state.ose_card_store.timer_id = timer.initTimer(
new Date(data.start_date).getTime(),
new Date(data.end_date).getTime(),
null,
data.carousel_days
)
}
}
)
watch(
() => data.landing_page,
() => {
try {
data.origin = new URL(data.landing_page).origin
} catch (error) {
// Ignore malformed URLs while the user is still typing.
}
}
)
return {
dynamicPhoneVisible,
dynamicComputerVisible,
prepareDynamicPreviewForSave,
}
}

View File

@@ -0,0 +1,108 @@
import { ref } from "vue"
import { ElMessage } from "element-plus"
export function useOseCardEditPage({ data, props, query, router, store, beforeSave }) {
const previewButtonDisabled = ref(true)
const pageLoading = ref(false)
const initializePage = async () => {
for (const key in props) {
data[key] = props[key]
}
if (store.state.ose_card_store.timer_id) {
clearInterval(store.state.ose_card_store.timer_id)
}
if (query.id) {
for (const key in data) {
data[key] = store.state.ose_card_store.edit_card_data[key]
}
}
data.corporate_customer = store.state.user.current_corporate_customer
if (query.mode === "edit") {
previewButtonDisabled.value = false
}
if (store.state.ose_card_store.images.length === 0) {
await store.dispatch("ose_card_store/getImages")
}
}
const goBack = () => {
router.go(-1)
}
const saveRecord = async () => {
pageLoading.value = true
const params = {
mode: query.mode,
id: query.id,
item: data,
}
if (!data.card_name || !data.landing_page || !data.card_code || !data.start_date || !data.end_date) {
ElMessage({
showClose: true,
message: "【圖卡名稱、LandingPage、圖卡代碼、開始/結束時間】不得為空。",
type: "error",
})
pageLoading.value = false
return
}
const filterResult = await store.dispatch("ose_card_store/saveCardFilter", params)
if (filterResult.length > 0) {
ElMessage({
showClose: true,
message: "該圖卡有重複到【LandingPage、圖卡代碼、開始/結束時間】,請再次檢查。",
type: "warning",
})
pageLoading.value = false
return
}
if (beforeSave) {
beforeSave()
}
await store.dispatch("ose_card_store/saveCard", params)
ElMessage({
showClose: true,
message: "圖卡保存完成!",
type: "success",
})
if (query.mode !== "edit") {
router.go(-1)
}
pageLoading.value = false
}
const applyColorChange = (target, color) => {
if (typeof target === "string") {
data[target] = color
return
}
for (const key of target) {
data[key] = color
}
}
return {
pageLoading,
previewButtonDisabled,
initializePage,
goBack,
saveRecord,
applyColorChange,
}
}

View File

@@ -0,0 +1,60 @@
import { computed, ref } from "vue"
import { buildOseCardCouponUrl, buildOseCardPreviewUrl } from "@/module/ose-card/service/card-url"
export function useOseCardEditSupport({ data, store }) {
const imageDrawerVisible = ref(false)
const couponDrawerVisible = ref(false)
const couponType = ref("")
const couponForm = ref({
coupon_code: "",
affiliate_code: "",
})
const cardCodeUrl = computed(() =>
buildOseCardCouponUrl(
data.landing_page,
data.card_code,
couponType.value,
couponForm.value.coupon_code,
couponForm.value.affiliate_code
)
)
const openImageDrawer = () => {
imageDrawerVisible.value = true
}
const openCouponDrawer = () => {
couponDrawerVisible.value = true
}
const previewCard = () => {
window.open(buildOseCardPreviewUrl(data.landing_page, data.card_code))
}
// Keep upload and image selection side effects close to the drawer state,
// so the main edit page can focus on the card form itself.
const uploadImage = async (event) => {
const form = new FormData()
form.append("file", event.file)
await store.dispatch("ose_card_store/uploadImages", form)
}
const selectImage = (imageUrl) => {
data.card_img_url = imageUrl
}
return {
imageDrawerVisible,
couponDrawerVisible,
couponType,
couponForm,
cardCodeUrl,
openImageDrawer,
openCouponDrawer,
previewCard,
uploadImage,
selectImage,
}
}

View File

@@ -0,0 +1,115 @@
import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { buildOseCardPreviewUrl } from "@/module/ose-card/service/card-url";
export const useOseCardListPage = ({ store, router, tableColumns }) => {
const search = ref("");
const carouselDaysSearch = ref(false);
const tableLoad = ref(true);
const filterTableData = computed(() => {
let searchResult = store.state.ose_card_store.card_list;
if (search.value) {
searchResult = searchResult.filter((data) => {
for (const column of tableColumns) {
try {
if (
data[column.id]
.toLowerCase()
.includes(search.value.toLowerCase())
) {
return true;
}
} catch (error) {
continue;
}
}
return false;
});
}
if (carouselDaysSearch.value) {
searchResult = searchResult.filter((data) => data.carousel_days > 0);
}
return searchResult;
});
const openEditPage = async (queryParams) => {
tableLoad.value = true;
await router.push({
name: "ose-card-edit",
query: queryParams,
});
tableLoad.value = false;
};
const loadCardList = async () => {
tableLoad.value = true;
await store.dispatch(
"ose_card_store/getCardList",
store.state.user.current_corporate_customer
);
tableLoad.value = false;
};
const previewCard = (rowItem) => {
window.open(buildOseCardPreviewUrl(rowItem.landing_page, rowItem.card_code));
};
const editCard = (rowItem) => {
store.commit("ose_card_store/edit_card_data", rowItem);
openEditPage({
id: rowItem.id,
mode: "edit",
});
};
const copyCard = (rowItem) => {
store.commit("ose_card_store/edit_card_data", rowItem);
openEditPage({
id: rowItem.id,
mode: "copy",
});
};
const deleteCard = async (rowItem) => {
try {
await ElMessageBox.confirm("請再次確認是否該刪除?", "注意", {
confirmButtonText: "確認",
cancelButtonText: "取消",
type: "warning",
});
tableLoad.value = true;
await store.dispatch("ose_card_store/deleteCard", rowItem.id);
tableLoad.value = false;
ElMessage({
type: "success",
message: "刪除成功",
});
} catch (error) {
tableLoad.value = false;
ElMessage({
type: "info",
message: "刪除取消",
});
}
};
return {
search,
carouselDaysSearch,
tableLoad,
filterTableData,
openEditPage,
loadCardList,
previewCard,
editCard,
copyCard,
deleteCard,
};
};

View File

@@ -0,0 +1,121 @@
import {
deleteCardById,
getCardFolderId,
listCardImages,
listCards,
listCardsByFilter,
moveCardFileToFolder,
saveCardItem,
uploadCardImage,
} from "@/module/ose-card/service/card-service";
export default {
namespaced: true,
state: {
timer_id: null,
card_list: [],
edit_card_data: JSON.parse(sessionStorage.getItem('edit_card_data')) || {},
images: []
},
actions: {
async getCardList({rootState, commit, dispatch}, corporate_customer_id) {
const client = rootState.directus.client
const result = await listCards(client, corporate_customer_id);
commit("card_list", result)
},
async deleteCard({rootState, commit, dispatch}, id) {
const client = rootState.directus.client
await deleteCardById(client, id);
await dispatch("getCardList", rootState.user.current_corporate_customer)
},
async saveCardFilter({rootState, commit, dispatch}, options) {
let start_date = new Date(options.item.start_date)
start_date.setHours(new Date(options.item.start_date).getHours()+8)
let end_date = new Date(options.item.end_date)
end_date.setHours(new Date(options.item.end_date).getHours()+8)
let filter = {
_and: [
{
origin: {
_eq: options.item.origin
}
},
{
card_code: {
_eq: options.item.card_code
}
},
{
_or: [
{
start_date: {
_between: [
start_date.toISOString(),
end_date.toISOString()
]
}
},
{
end_date: {
_between: [
start_date.toISOString(),
end_date.toISOString()
]
}
}
]
}
]
}
if (options.mode == 'edit')
filter._and.push({
id: {
_neq: options.id
}
})
const client = rootState.directus.client
const result = await listCardsByFilter(client, filter);
return result
},
async saveCard({rootState, commit, dispatch}, options) {
const client = rootState.directus.client
return await saveCardItem(client, options)
},
async getImages({rootState, commit, dispatch}) {
const client = rootState.directus.client
const result = await listCardImages(client)
commit("images", result)
},
async getFolder({rootState, commit, dispatch}) {
const client = rootState.directus.client
return await getCardFolderId(client)
},
async changeFileFolder({rootState, commit, dispatch}, fileId) {
const client = rootState.directus.client
const folderId = await dispatch('getFolder')
await moveCardFileToFolder(client, fileId, folderId)
},
async uploadImages({rootState, commit, dispatch}, file) {
const client = rootState.directus.client
const folderId = await dispatch('getFolder')
const result = await uploadCardImage(client, file, folderId)
commit("addNewImage", {url: result.url})
}
},
mutations: {
card_list(state, val) {
state.card_list = val
},
edit_card_data(state, val) {
state.edit_card_data = val
},
images(state, val) {
state.images = val
},
addNewImage(state, val) {
state.images.unshift(val)
}
},
getters: {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
<template>
<el-main class="tw-h-full">
<el-row class="tw-h-full">
<el-col class="tw-h-full">
<el-card shadow="never" class="tw-h-full" body-class="tw-h-full">
<section class="tw-space-y-6">
<legacy-module-banner
title="ose-card 目前仍是 legacy 行銷圖卡模組"
description="它會先維持營運可用,之後再對照新的 experiment / editor 主線逐步重構;這一區目前不作為新功能主入口。"
/>
<section class="tw-flex tw-items-start tw-justify-between tw-gap-6">
<div class="tw-space-y-2">
<p class="tw-text-sm tw-font-medium tw-uppercase tw-tracking-[0.2em] tw-text-slate-400">
OSE Card
</p>
<div class="tw-space-y-1">
<h2 class="tw-text-3xl tw-font-bold tw-text-slate-900">行銷圖卡管理</h2>
<p class="tw-text-sm tw-leading-6 tw-text-slate-500">
先維持既有圖卡模組可用同時把列表編輯與預覽流程逐步收斂成更乾淨的模組邊界
</p>
</div>
</div>
<el-button type="primary" size="large" :color="'#2f91ee'" @click="openEditPage()" plain>
新增設定檔
</el-button>
</section>
<el-row class="tw-items-center">
<el-col :span="12" class="tw-flex tw-gap-6 tw-items-center">
<el-input
v-model="search"
size="large"
placeholder="搜尋"
suffix-icon="Search"/>
<p class="tw-w-full">
查詢開啟循環天數
<el-switch v-model="carouselDaysSearch" aria-label="test"/>
</p>
</el-col>
</el-row>
</section>
<el-divider></el-divider>
<el-table stripe border style="height: 87%" :data="filterTableData" v-loading="tableLoad"
:default-sort="{prop:'id',order:'descending'}">
<el-table-column v-for="(item,index) in table" :prop="item.id" :label="item.name"
:width="item.header_setup.width" :sortable="item.header_setup.sortable"
:show-overflow-tooltip="item.header_setup['show-overflow-tooltip']"/>
<el-table-column fixed="right" label="操作" width="150">
<template #default="scope">
<ose-card-table-actions
@preview="previewCard(scope.row)"
@edit="editCard(scope.row)"
@copy="copyCard(scope.row)"
@delete="deleteCard(scope.row)"
/>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</el-main>
</template>
<script setup>
import LegacyModuleBanner from "@/components/LegacyModuleBanner.vue";
import OseCardTableActions from "@/module/ose-card/components/OseCardTableActions.vue";
import {useRouter} from "vue-router";
import {useStore} from "vuex";
import table from "@/module/ose-card/model/table";
import { useOseCardListPage } from "@/module/ose-card/service/use-ose-card-list-page";
const store = useStore();
const router = useRouter()
const {
search,
carouselDaysSearch,
tableLoad,
filterTableData,
openEditPage,
loadCardList,
previewCard,
editCard,
copyCard,
deleteCard,
} = useOseCardListPage({
store,
router,
tableColumns: table,
});
loadCardList()
</script>
<style scoped>
</style>

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>

View File

@@ -0,0 +1,123 @@
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '編輯變體' : '新增變體'"
width="440px"
:close-on-click-modal="false"
@update:model-value="$emit('update:modelValue', $event)"
@closed="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent
>
<el-form-item label="變體名稱" prop="name">
<el-input v-model="form.name" placeholder="例:原始版本 / 紅色 CTA 版本" />
</el-form-item>
<el-form-item label="流量權重" prop="traffic_weight">
<el-input-number
v-model="form.traffic_weight"
:min="0"
:max="100"
style="width: 100%"
/>
<p style="color: #64748b; font-size: 12px; margin-top: 4px">
各版本的流量比例系統會依權重分配流量
</p>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">
{{ isEdit ? "儲存" : "建立" }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue"
import { ElMessage } from "element-plus"
import variantApi from "@/module/variant/service/variant-api"
const props = defineProps({
modelValue: { type: Boolean, default: false },
experimentId: { type: String, default: "" },
variant: { type: Object, default: null },
})
const emit = defineEmits(["update:modelValue", "saved"])
const isEdit = computed(() => Boolean(props.variant?.id))
const formRef = ref(null)
const saving = ref(false)
const defaultForm = () => ({
name: "",
traffic_weight: 0,
})
const form = ref(defaultForm())
const rules = {
name: [{ required: true, message: "請填入變體名稱", trigger: "blur" }],
traffic_weight: [{ required: true, message: "請設定流量權重", trigger: "blur" }],
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
if (props.variant) {
form.value = {
name: props.variant.name || "",
traffic_weight: props.variant.trafficWeight ?? props.variant.traffic_weight ?? 0,
}
} else {
form.value = defaultForm()
}
}
)
const resetForm = () => {
form.value = defaultForm()
formRef.value?.clearValidate()
}
const submit = async () => {
await formRef.value?.validate().catch(() => { throw new Error("validate") })
saving.value = true
try {
const payload = {
name: form.value.name,
traffic_weight: form.value.traffic_weight,
}
let result
if (isEdit.value) {
result = await variantApi.updateVariant(props.variant.id, payload)
} else {
result = await variantApi.createVariant({
...payload,
experiment_id: props.experimentId,
})
}
ElMessage.success(isEdit.value ? "變體已更新" : "變體已建立")
emit("update:modelValue", false)
emit("saved", result)
} catch (error) {
ElMessage.error(error?.message || "操作失敗")
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,77 @@
import { formatDateTime } from "@/module/experiment/model/experiment-view-model";
export const mapVariantDetail = (item = {}) => ({
id: item.id || "",
experimentId: item.experiment_id || "",
variantKey: item.variant_key || "",
name: item.name || "未命名 Variant",
trafficWeight: item.traffic_weight ?? 0,
contentConfig: item.content_config || {},
});
export const summarizeVariantChanges = (items = []) => ({
total: items.length,
text: items.filter((item) => item.change_type === "replace_text").length,
html: items.filter((item) => item.change_type === "set_html").length,
style: items.filter((item) => item.change_type === "set_style").length,
attribute: items.filter((item) => item.change_type === "set_attribute").length,
});
export const buildVariantEditorEntry = ({ variant, changes = [], baseUrl }) => {
if (!baseUrl) {
return {
level: "warning",
title: "尚未帶入可編輯頁面網址",
description: "先從實驗設定帶入 base URL進 editor 後才會直接定位到正確頁面。",
actionLabel: "先補頁面網址",
};
}
if (!variant?.id) {
return {
level: "warning",
title: "Variant 尚未就緒",
description: "先確認 variant 基本資料,再進入 editor。",
actionLabel: "等待載入",
};
}
if (changes.length === 0) {
return {
level: "info",
title: "可以開始建立第一個修改內容",
description: "目前還沒有變更內容,適合直接進 editor 先建立第一筆調整。",
actionLabel: "開始編輯這個 Variant",
};
}
return {
level: "ready",
title: "這個 Variant 已有修改內容",
description: "可以直接進 editor 繼續調整,或回頭檢查 preview 與 runtime payload。",
actionLabel: "繼續編輯這個 Variant",
};
};
export const buildVariantRoleLabel = () => "實驗版本";
export const mapVariantChangeCards = (items = []) =>
items.map((item, index) => ({
id: `${item.selector_value || "selector"}-${item.change_type || "change"}-${index}`,
selector: item.selector_value || "未指定區塊",
changeType: item.change_type || "unknown",
changeTypeLabel:
item.change_type === "replace_text"
? "替換文字"
: item.change_type === "set_html"
? "替換 HTML"
: item.change_type === "set_style"
? "調整樣式"
: item.change_type === "set_attribute"
? "調整屬性"
: item.change_type || "未命名操作",
payloadPreview: JSON.stringify(item.payload || {}, null, 2),
}));
export const buildVariantUpdatedHint = (value) =>
value ? formatDateTime(value) : "目前尚未記錄更新時間";

View File

@@ -0,0 +1,183 @@
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import editorApi from "@/module/editor/service/editor-api";
import experimentApi from "@/module/experiment/service/experiment-api";
import {
mapExperimentDetail,
resolveExperimentBaseUrl,
} from "@/module/experiment/model/experiment-view-model";
import releaseApi from "@/module/release/service/release-api";
import variantApi from "@/module/variant/service/variant-api";
import {
buildVariantEditorEntry,
mapVariantChangeCards,
mapVariantDetail,
summarizeVariantChanges,
} from "@/module/variant/model/variant-view-model";
export function useVariantDetailPage() {
const route = useRoute();
const router = useRouter();
const variant = ref(null);
const experiment = ref(null);
const releases = ref([]);
const changes = ref([]);
const loading = ref(false);
const errorMessage = ref("");
const variantId = computed(() => String(route.params.variantId || ""));
const changesSummary = computed(() => summarizeVariantChanges(changes.value));
const changeCards = computed(() => mapVariantChangeCards(changes.value));
const recommendedBaseUrl = computed(() => resolveExperimentBaseUrl(experiment.value));
const editorEntry = computed(() =>
buildVariantEditorEntry({
variant: variant.value,
changes: changes.value,
baseUrl: recommendedBaseUrl.value,
})
);
const latestRelease = computed(() => releases.value[0] || null);
const nextStepSummary = computed(() => {
if (changes.value.length === 0) {
return {
title: "先建立第一筆修改",
description:
"這個變體目前還沒有任何修改內容,先進編輯器建立第一筆變更,後面才能更有效檢查版本結果。",
actionLabel: "開始修改這個變體",
actionType: "editor",
};
}
if (!latestRelease.value?.id) {
return {
title: "先確認最新版本",
description:
"目前這個變體已有修改內容,下一步適合先看最新版本是否已收進 runtime payload。",
actionLabel: "查看最新版本",
actionType: "release",
};
}
return {
title: "回到編輯器做細部調整",
description:
"目前已有變更與版本資訊,適合回到編輯器微調內容,再重新檢查畫面結果。",
actionLabel: "進入編輯器",
actionType: "editor",
};
});
const loadPage = async () => {
loading.value = true;
errorMessage.value = "";
try {
const variantItem = await variantApi.getVariant(variantId.value);
const mappedVariant = mapVariantDetail(variantItem);
variant.value = mappedVariant;
const [experimentItem, changeItems, releaseItems] = await Promise.all([
experimentApi.getExperiment(mappedVariant.experimentId),
editorApi.getVariantChanges(mappedVariant.id),
releaseApi.listReleases({ experimentId: mappedVariant.experimentId }),
]);
experiment.value = mapExperimentDetail(experimentItem);
changes.value = changeItems;
releases.value = releaseItems;
} catch (error) {
errorMessage.value = error?.message || "無法載入 variant detail。";
variant.value = null;
experiment.value = null;
releases.value = [];
changes.value = [];
} finally {
loading.value = false;
}
};
const openEditor = () => {
if (!variant.value?.id) {
return;
}
router.push({
name: "editor-variant",
params: { variantId: variant.value.id },
query: recommendedBaseUrl.value
? {
base_url: recommendedBaseUrl.value,
}
: undefined,
});
};
const openExperiment = () => {
if (!variant.value?.experimentId) {
return;
}
router.push({
name: "experiment-detail",
params: { experimentId: variant.value.experimentId },
});
};
const openLatestRelease = () => {
if (!latestRelease.value?.id) {
return;
}
router.push({
name: "release-detail",
params: { releaseId: latestRelease.value.id },
});
};
const goBack = () => {
if (variant.value?.experimentId) {
openExperiment();
return;
}
router.push({ name: "experiments" });
};
const runNextStep = () => {
if (nextStepSummary.value.actionType === "release") {
openLatestRelease();
return;
}
openEditor();
};
onMounted(async () => {
await loadPage();
});
return {
route,
loading,
errorMessage,
variant,
experiment,
releases,
changes,
changesSummary,
changeCards,
recommendedBaseUrl,
editorEntry,
latestRelease,
nextStepSummary,
loadPage,
openEditor,
openExperiment,
openLatestRelease,
goBack,
runNextStep,
};
}

View File

@@ -0,0 +1,34 @@
import fastapiClient from "@/services/api/fastapi-client";
const listVariants = async ({ experimentId } = {}) => {
const query = experimentId
? `?experiment_id=${encodeURIComponent(experimentId)}`
: "";
const response = await fastapiClient.request(`/api/admin/variants${query}`);
return response.items || [];
};
const getVariant = async (variantId) => {
return await fastapiClient.request(`/api/admin/variants/${variantId}`);
};
const createVariant = async (data) => {
return await fastapiClient.request("/api/admin/variants", {
method: "POST",
body: JSON.stringify(data),
});
};
const updateVariant = async (variantId, data) => {
return await fastapiClient.request(`/api/admin/variants/${variantId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
};
export default {
listVariants,
getVariant,
createVariant,
updateVariant,
};

View File

@@ -0,0 +1,403 @@
<template>
<AdminPageShell
eyebrow="變體詳情"
:title="variant?.name || '載入中...'"
:description="variant?.variantKey || route.params.variantId"
>
<template #actions>
<el-button @click="goBack">返回實驗</el-button>
<el-button @click="loadPage">重新整理</el-button>
<el-button :disabled="!latestRelease?.id" @click="openLatestRelease">查看最新版本</el-button>
<el-button type="primary" :disabled="!variant?.id" @click="openEditor">打開編輯器</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="(variant?.trafficWeight ?? 0) + '%'" tone="info" hint="這個變體分配到的流量比例" />
<AdminMetricCard label="視覺變更" :value="changesSummary.total" tone="success" hint="已設定的頁面修改數量" />
</div>
</template>
<div class="two-col">
<!-- Left: config + changes -->
<div class="left-col">
<div class="card">
<div class="card__header">
<p class="card__title">變體資訊</p>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="變體代號">{{ variant?.variantKey || "-" }}</el-descriptions-item>
<el-descriptions-item label="所屬實驗">
<el-button link type="primary" :disabled="!variant?.experimentId" @click="openExperiment">
{{ experiment?.name || variant?.experimentId || "-" }}
</el-button>
</el-descriptions-item>
<el-descriptions-item label="套用頁面網址">
<span class="break-all">{{ recommendedBaseUrl || "尚未設定頁面網址" }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<div class="card">
<div class="card__header">
<p class="card__title">變更摘要</p>
<el-button size="small" type="primary" :disabled="!variant?.id" @click="openEditor">
繼續編輯這個變體
</el-button>
</div>
<div class="change-stats">
<div class="change-stat">
<p class="change-stat__label">文字</p>
<p class="change-stat__value">{{ changesSummary.text }}</p>
</div>
<div class="change-stat">
<p class="change-stat__label">HTML</p>
<p class="change-stat__value">{{ changesSummary.html }}</p>
</div>
<div class="change-stat">
<p class="change-stat__label">樣式</p>
<p class="change-stat__value">{{ changesSummary.style }}</p>
</div>
<div class="change-stat">
<p class="change-stat__label">屬性</p>
<p class="change-stat__value">{{ changesSummary.attribute }}</p>
</div>
</div>
<div v-if="changeCards.length === 0" class="empty-state">
目前這個 Variant 還沒有任何變更內容適合直接進 Editor 建立第一筆修改
</div>
<div v-else class="change-list">
<div v-for="card in changeCards" :key="card.id" class="change-item">
<div class="change-item__top">
<div>
<p class="change-item__type">{{ card.changeTypeLabel }}</p>
<p class="change-item__selector">{{ card.selector }}</p>
</div>
</div>
<pre class="code-block"><code>{{ card.payloadPreview }}</code></pre>
</div>
</div>
</div>
</div>
<!-- Right: editor state + next steps -->
<div class="side-col">
<div class="card">
<p class="card__title">編輯器狀態</p>
<div
class="status-block"
:class="
editorEntry.level === 'ready' ? 'status-block--success'
: editorEntry.level === 'info' ? 'status-block--info'
: 'status-block--warning'
"
>
<p class="status-block__heading">{{ editorEntry.title }}</p>
<p class="status-block__desc">{{ editorEntry.description }}</p>
<el-button class="status-block__btn" size="small" type="primary" :disabled="!variant?.id" @click="openEditor">
{{ editorEntry.actionLabel }}
</el-button>
</div>
</div>
<div class="card">
<p class="card__title">內容設定</p>
<pre class="code-block"><code>{{ JSON.stringify(variant?.contentConfig || {}, null, 2) }}</code></pre>
</div>
<div class="card">
<p class="card__title" style="margin-bottom: 14px">下一步建議</p>
<div class="info-row">
<p class="info-row__label">最新版本</p>
<p class="info-row__value">{{ latestRelease?.release_version || latestRelease?.version_no || "尚未建立版本" }}</p>
<p class="info-row__sub">
{{
latestRelease?.id
? "你可以先看最新版本是否已帶出 runtime payload再回到 editor 做細節調整。"
: "目前還沒有版本,可先進 editor 完成變更與預覽。"
}}
</p>
<div class="info-row__actions">
<el-button size="small" :disabled="!latestRelease?.id" @click="openLatestRelease">查看最新版本</el-button>
<el-button size="small" type="primary" :disabled="!variant?.id" @click="openEditor">進入編輯器</el-button>
</div>
</div>
<div class="status-block status-block--info">
<p class="info-row__label" style="color: #0284c7">現在建議做的事</p>
<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" @click="runNextStep">
{{ nextStepSummary.actionLabel }}
</el-button>
</div>
</div>
</div>
</div>
</AdminPageShell>
</template>
<script setup>
import AdminMetricCard from "@/components/AdminMetricCard.vue";
import AdminPageShell from "@/components/AdminPageShell.vue";
import { useVariantDetailPage } from "@/module/variant/service/use-variant-detail-page";
const {
route,
errorMessage,
variant,
experiment,
changesSummary,
changeCards,
recommendedBaseUrl,
editorEntry,
latestRelease,
nextStepSummary,
loadPage,
openEditor,
openExperiment,
openLatestRelease,
goBack,
runNextStep,
} = useVariantDetailPage();
</script>
<style scoped>
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 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;
}
/* Change stats */
.change-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 4px;
}
.change-stat {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
background: #f8fafc;
}
.change-stat__label {
margin: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #94a3b8;
}
.change-stat__value {
margin: 6px 0 0;
font-size: 22px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
line-height: 1;
}
/* Change list */
.change-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.change-item {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.change-item__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.change-item__type {
margin: 0;
font-size: 13.5px;
font-weight: 600;
color: #0f172a;
}
.change-item__selector {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
word-break: break-all;
}
/* 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__btn {
align-self: flex-start;
margin-top: 4px;
}
/* Info rows */
.info-row {
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.info-row__label {
margin: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #94a3b8;
}
.info-row__value {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.info-row__sub {
margin: 0;
font-size: 12px;
color: #64748b;
line-height: 1.6;
}
.info-row__actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
/* Empty state */
.empty-state {
border: 1px dashed #e2e8f0;
border-radius: 12px;
background: #f8fafc;
padding: 28px;
text-align: center;
font-size: 13.5px;
color: #94a3b8;
}
.break-all {
word-break: break-all;
}
</style>