first commit

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

View File

@@ -0,0 +1,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>