feat: 添加转账功能,新增转账页面及相关逻辑,优化交易密码验证流程

This commit is contained in:
2026-01-19 00:55:56 +07:00
parent a4f939bec4
commit 90a4efc81a
4 changed files with 594 additions and 5 deletions

View File

@@ -129,6 +129,11 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("@/views/withdraw/index.vue"),
meta: { requiresAuth: true },
},
{
path: "/transfer",
component: () => import("@/views/transfer/index.vue"),
meta: { requiresAuth: true },
},
];
const router = createRouter({

View File

@@ -122,7 +122,8 @@ async function handleLogout() {
<div class="flex gap-3">
<ion-button
expand="block"
class="flex-1 recharge-btn"
fill="outline"
class="flex-1 withdraw-btn"
@click="handleRecharge"
>
<ion-icon slot="start" :icon="arrowDownOutline" />

View File

@@ -4,9 +4,9 @@ import { checkmarkCircleOutline, chevronForwardOutline, closeCircleOutline, keyO
import { client, safeClient } from "@/api";
const router = useRouter();
const { data: status, refresh } = await safeClient(() => client.api.user.security["transaction-password"].status.get());
const { data: status, execute } = await safeClient(() => client.api.user.security["transaction-password"].status.get());
const hasPaymentPassword = computed(() => status.value?.enabled ?? false);
const toggleInst = ref<HTMLIonToggleElement | null>(null);
async function showToast(message: string, color: "success" | "danger" | "warning" = "success") {
const toast = await toastController.create({
@@ -50,6 +50,7 @@ async function handleTogglePaymentPassword() {
},
{
text: "确认",
role: "confirm",
handler: async (data) => {
if (!data.oldPassword) {
await showToast("请输入交易密码", "danger");
@@ -66,7 +67,7 @@ async function handleTogglePaymentPassword() {
if (!error.value) {
await showToast("交易密码功能已关闭", "warning");
await refresh();
await execute();
return true;
}
return false;
@@ -132,7 +133,7 @@ async function handleTogglePaymentPassword() {
if (!error.value) {
await showToast("交易密码设置成功");
await refresh();
await execute();
return true;
}
return false;
@@ -197,6 +198,7 @@ async function handleTogglePaymentPassword() {
<span>{{ status?.enabled ? '已启用' : '未启用' }}</span>
</div>
<ion-toggle
ref="toggleInst"
:checked="status?.enabled"
color="danger"
@ion-change="handleTogglePaymentPassword"

View File

@@ -0,0 +1,581 @@
<script lang='ts' setup>
import type { Treaty } from "@elysiajs/eden";
import type { client } from "@/api";
import { alertController, toastController } from "@ionic/vue";
import { callOutline, checkmarkCircleOutline, documentTextOutline, keyOutline, personOutline, walletOutline } from "ionicons/icons";
import { safeClient } from "@/api";
type Wallet = Treaty.Data<typeof client.api.wallet.wallets.get>[number];
const router = useRouter();
const walletStore = useWalletStore();
const { wallets } = storeToRefs(walletStore);
const filterWallets = computed(() => wallets.value.filter(w => w.walletType.allowTransfer === true));
// 转账金额
const transferAmount = ref("");
const quickAmounts = [100, 500, 1000, 2000];
// 选中的钱包
const selectedWallet = ref<Wallet | null>(null);
// 收款人手机号
const recipientPhone = ref("");
// 交易密码
const transactionPassword = ref("");
onMounted(async () => {
await walletStore.syncWallets();
if (wallets.value.length > 0) {
selectedWallet.value = wallets.value[0];
}
});
// 可转账余额
const availableBalance = computed(() => {
return Number(selectedWallet.value?.available) || 0;
});
function selectQuickAmount(amount: number) {
if (amount <= availableBalance.value) {
transferAmount.value = amount.toString();
}
else {
showToast("转账金额不能大于可用余额", "warning");
}
}
function selectAllAmount() {
transferAmount.value = availableBalance.value.toString();
}
function selectWallet(wallet: Wallet) {
selectedWallet.value = wallet;
// 重新验证转账金额
const amount = Number.parseFloat(transferAmount.value);
if (amount > availableBalance.value) {
transferAmount.value = "";
}
}
async function showToast(message: string, color: "success" | "danger" | "warning" = "success") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
// 验证手机号
function validatePhone(phone: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
}
async function handleSubmit() {
const amount = Number.parseFloat(transferAmount.value);
// 验证转账金额
if (!transferAmount.value || Number.isNaN(amount) || amount <= 0) {
await showToast("请输入有效的转账金额", "warning");
return;
}
if (amount < 1) {
await showToast("转账金额不能低于1元", "warning");
return;
}
if (amount > availableBalance.value) {
await showToast("转账金额不能大于可用余额", "warning");
return;
}
// 验证钱包选择
if (!selectedWallet.value) {
await showToast("请选择转账钱包", "warning");
return;
}
// 验证收款人手机号
if (!recipientPhone.value) {
await showToast("请输入收款人手机号", "warning");
return;
}
if (!validatePhone(recipientPhone.value)) {
await showToast("请输入正确的手机号码", "warning");
return;
}
// 验证交易密码
if (!transactionPassword.value) {
await showToast("请输入交易密码", "warning");
return;
}
if (transactionPassword.value.length < 6) {
await showToast("交易密码至少6位", "warning");
return;
}
const alert = await alertController.create({
header: "确认转账",
message: `
<div style="text-align: left; padding: 12px 0;">
<p style="margin: 8px 0;"><strong>转账金额:</strong>¥${amount.toFixed(2)}</p>
<p style="margin: 8px 0;"><strong>转账钱包:</strong>${selectedWallet.value.walletType.name}</p>
<p style="margin: 8px 0;"><strong>收款人:</strong>${recipientPhone.value}</p>
</div>
`,
buttons: [
{
text: "取消",
role: "cancel",
},
{
text: "确认转账",
handler: async () => {
// TODO: 调用转账 API
// const { error } = await safeClient(client.api.transfer.post({
// walletId: selectedWallet.value.id,
// amount,
// recipientPhone: recipientPhone.value,
// transactionPassword: transactionPassword.value,
// }));
await showToast("转账成功");
transferAmount.value = "";
recipientPhone.value = "";
transactionPassword.value = "";
await walletStore.syncWallets();
},
},
],
});
await alert.present();
}
function goToRecords() {
// router.push("/transfer/records");
showToast("功能开发中", "warning");
}
</script>
<template>
<ion-page>
<ion-header class="ion-no-border">
<ion-toolbar class="ion-toolbar">
<ion-buttons slot="start">
<back-button />
</ion-buttons>
<ion-title>转账</ion-title>
<ion-buttons slot="end">
<ion-button color="light" size="small" @click="goToRecords">
<ion-icon slot="icon-only" :icon="documentTextOutline" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- 选择钱包 -->
<div class="section-card">
<div class="section-title">
<ion-icon :icon="walletOutline" class="title-icon" />
选择转账钱包
</div>
<div v-if="wallets.length > 0" class="wallet-list">
<div
v-for="wallet in filterWallets"
:key="wallet.id"
class="wallet-item"
:class="{ active: selectedWallet?.id === wallet.id }"
@click="selectWallet(wallet)"
>
<div class="wallet-info">
<div class="wallet-name">
{{ wallet.walletType.name }}
</div>
<div class="wallet-balance">
可用余额¥{{ Number(wallet.available).toFixed(2) }}
</div>
</div>
<div v-if="selectedWallet?.id === wallet.id" class="wallet-check">
<ion-icon :icon="checkmarkCircleOutline" />
</div>
</div>
</div>
<div v-else class="no-wallets">
<empty title="暂无钱包" />
</div>
</div>
<!-- 收款人信息 -->
<div class="section-card">
<div class="section-title">
<ion-icon :icon="personOutline" class="title-icon" />
收款人信息
</div>
<div class="input-wrapper">
<ion-input
v-model="recipientPhone"
type="tel"
placeholder="请输入收款人手机号"
class="phone-input"
inputmode="tel"
:maxlength="11"
/>
</div>
<div class="input-hint">
<ion-icon :icon="checkmarkCircleOutline" class="hint-icon" />
请输入收款人的注册手机号码
</div>
</div>
<!-- 转账金额输入 -->
<div class="section-card">
<div class="section-title">
转账金额
</div>
<div class="amount-input-wrapper">
<span class="currency-symbol">¥</span>
<input
v-model="transferAmount"
type="number"
inputmode="decimal"
placeholder="请输入转账金额"
class="amount-input"
:max="availableBalance"
>
</div>
<!-- 快速选择金额 -->
<div class="quick-amounts">
<div
v-for="amount in quickAmounts"
:key="amount"
class="quick-amount-btn"
:class="{
active: transferAmount === amount.toString(),
disabled: amount > availableBalance,
}"
@click="selectQuickAmount(amount)"
>
¥{{ amount }}
</div>
<div
class="quick-amount-btn"
:class="{ active: transferAmount === availableBalance.toString() }"
@click="selectAllAmount"
>
全部
</div>
</div>
<div class="amount-hint">
<ion-icon :icon="checkmarkCircleOutline" class="hint-icon" />
可转账余额¥{{ availableBalance.toFixed(2) }}最低转账金额 ¥1
</div>
</div>
<!-- 交易密码 -->
<div class="section-card">
<div class="section-title">
<ion-icon :icon="keyOutline" class="title-icon" />
交易密码
</div>
<div class="password-input-wrapper">
<ion-input
v-model="transactionPassword"
type="password"
placeholder="请输入交易密码"
class="password-input"
inputmode="numeric"
:maxlength="20"
/>
</div>
<div class="password-hint">
交易密码用于验证转账操作请妥善保管
</div>
</div>
<!-- 提交按钮 -->
<div class="submit-wrapper">
<ion-button
expand="block"
class="submit-btn"
@click="handleSubmit"
>
确认转账
</ion-button>
</div>
<!-- 温馨提示 -->
<div class="notice-card">
<div class="notice-title">
温馨提示
</div>
<div class="notice-content">
<p>1. 转账将实时到账请确认收款人信息无误</p>
<p>2. 转账金额最低1元请确保账户余额充足</p>
<p>3. 转账完成后资金将立即从您的账户中扣除</p>
<p>4. 转账记录可在资产明细中查看</p>
<p>5. 如遇问题请及时联系客服处理</p>
</div>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
.section-card {
background: white;
padding: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.title-icon {
font-size: 20px;
color: var(--ion-color-primary);
}
.wallet-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.wallet-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f5f5f5;
border: 2px solid transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.wallet-item.active {
background: #fff5f5;
border-color: var(--ion-color-primary);
}
.wallet-item:active {
transform: scale(0.98);
}
.wallet-info {
flex: 1;
}
.wallet-name {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.wallet-balance {
font-size: 13px;
color: #666;
}
.wallet-check {
color: var(--ion-color-primary);
font-size: 24px;
}
.no-wallets {
padding: 40px 0;
}
.input-wrapper {
margin-bottom: 8px;
}
.input-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: #666;
margin-bottom: 8px;
}
.label-icon {
font-size: 18px;
color: var(--ion-color-primary);
}
.phone-input {
--background: #f5f5f5;
--border-radius: 12px;
--padding-start: 16px;
--padding-end: 16px;
font-size: 16px;
font-weight: 500;
}
.input-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #999;
margin-top: 8px;
}
.hint-icon {
font-size: 14px;
color: var(--ion-color-success);
}
.amount-input-wrapper {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 16px;
}
.currency-symbol {
font-size: 24px;
font-weight: 600;
color: #333;
margin-right: 8px;
}
.amount-input {
flex: 1;
border: none;
background: transparent;
font-size: 24px;
font-weight: 600;
color: #333;
outline: none;
}
.amount-input::placeholder {
color: #bbb;
font-weight: 400;
}
.quick-amounts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.quick-amount-btn {
padding: 12px;
background: #f5f5f5;
border: 2px solid transparent;
border-radius: 8px;
text-align: center;
font-size: 15px;
font-weight: 600;
color: #666;
cursor: pointer;
transition: all 0.3s ease;
}
.quick-amount-btn.active {
background: white;
border-color: var(--ion-color-primary);
color: var(--ion-color-primary);
}
.quick-amount-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.quick-amount-btn:not(.disabled):active {
transform: scale(0.95);
}
.amount-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #999;
}
.password-input-wrapper {
background: #f5f5f5;
border-radius: 12px;
padding: 4px 12px;
margin-bottom: 8px;
}
.password-input {
--padding-start: 8px;
--padding-end: 8px;
font-size: 16px;
}
.password-hint {
font-size: 12px;
color: #999;
line-height: 1.6;
}
.submit-wrapper {
padding: 0 20px 20px;
}
.submit-btn {
--background: linear-gradient(135deg, #c41e3a 0%, #8b1a2e 100%);
--background-activated: linear-gradient(135deg, #8b1a2e 0%, #c41e3a 100%);
--border-radius: 12px;
--padding-top: 14px;
--padding-bottom: 14px;
font-weight: 600;
font-size: 16px;
text-transform: none;
letter-spacing: 0.5px;
}
.notice-card {
background: #f9fafb;
padding: 16px 20px;
margin: 0 0 20px 0;
}
.notice-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.notice-content {
font-size: 12px;
color: #666;
line-height: 1.8;
}
.notice-content p {
margin: 6px 0;
}
</style>