feat: 添加提现表单验证规则,优化用户输入体验

This commit is contained in:
2025-12-17 15:59:11 +07:00
parent ef76de7a5c
commit 61cbcce579
4 changed files with 197 additions and 101 deletions

View File

@@ -40,7 +40,19 @@
"enterCryptoAddress": "Enter the crypto address", "enterCryptoAddress": "Enter the crypto address",
"validCryptoAddressError": "Please enter a valid crypto address.", "validCryptoAddressError": "Please enter a valid crypto address.",
"successMessage": "Withdrawal request submitted successfully!", "successMessage": "Withdrawal request submitted successfully!",
"submit": "Submit" "submit": "Submit",
"validation": {
"assetCodeRequired": "Please select a currency",
"amountRequired": "Please enter an amount",
"amountInvalid": "Please enter a valid number format",
"amountExceedsBalance": "Amount cannot exceed available balance",
"amountTooSmall": "Amount must be greater than 0",
"methodRequired": "Please select a withdrawal method",
"bankAccountRequired": "Please select a bank account",
"chainRequired": "Please select a blockchain network",
"addressRequired": "Please enter a withdrawal address",
"addressTooShort": "Address format is incorrect, too short"
}
}, },
"bankCard": { "bankCard": {
"management": "Bank Card Management", "management": "Bank Card Management",

View File

@@ -40,7 +40,19 @@
"enterCryptoAddress": "请输入加密货币地址", "enterCryptoAddress": "请输入加密货币地址",
"validCryptoAddressError": "请输入有效的加密货币地址。", "validCryptoAddressError": "请输入有效的加密货币地址。",
"successMessage": "提现申请提交成功!", "successMessage": "提现申请提交成功!",
"submit": "提交" "submit": "提交",
"validation": {
"assetCodeRequired": "请选择货币",
"amountRequired": "请输入金额",
"amountInvalid": "请输入有效的数字格式",
"amountExceedsBalance": "金额不能超过可用余额",
"amountTooSmall": "金额必须大于0",
"methodRequired": "请选择提现方式",
"bankAccountRequired": "请选择银行账户",
"chainRequired": "请选择区块链网络",
"addressRequired": "请输入提现地址",
"addressTooShort": "地址格式不正确,长度过短"
}
}, },
"bankCard": { "bankCard": {
"management": "银行卡管理", "management": "银行卡管理",

View File

@@ -1,50 +1,35 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { GenericObject } from "vee-validate";
import type { WithdrawBody } from "@/api/types"; import type { WithdrawBody } from "@/api/types";
import { toastController } from "@ionic/vue"; import { toastController } from "@ionic/vue";
import { ErrorMessage, Field, Form } from "vee-validate";
import { client, safeClient } from "@/api"; import { client, safeClient } from "@/api";
import { AssetCodeEnum, ChainEnum, WithdrawMethodEnum } from "@/api/enum"; import { AssetCodeEnum, ChainEnum, WithdrawMethodEnum } from "@/api/enum";
import { createWithdrawSchema } from "./rules";
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const walletStore = useWalletStore();
const { balances, bankAccounts } = storeToRefs(walletStore);
const amountInputInst = useTemplateRef<InputInstance>("amountInputInst"); const initialValues: WithdrawBody = {
const [form, resetForm] = useResetRef<WithdrawBody>({
assetCode: AssetCodeEnum.USDT, assetCode: AssetCodeEnum.USDT,
amount: "", amount: "",
withdrawMethod: WithdrawMethodEnum.BANK, withdrawMethod: WithdrawMethodEnum.BANK,
toAddress: "", toAddress: "",
bankAccountId: "", bankAccountId: "",
chain: "BEP20", chain: "BEP20",
}); };
const walletStore = useWalletStore();
const { balances, bankAccounts } = storeToRefs(walletStore);
const maxAmount = computed(() => { const maxAmount = computed(() => {
const balance = balances.value?.find(item => item.assetCode === form.value.assetCode); const balance = balances.value?.find(item => item.assetCode === initialValues.assetCode);
return balance ? balance.available : "0"; return balance ? balance.available : "0";
}); });
const router = useRouter();
function markTouched() { const schema = computed(() => createWithdrawSchema(t, maxAmount.value));
amountInputInst.value?.$el.classList.add("ion-touched");
}
function validate(value: string) {
amountInputInst.value?.$el.classList.remove("ion-valid");
amountInputInst.value?.$el.classList.remove("ion-invalid");
if (value === "") { async function onSubmit(values: GenericObject) {
return false; await safeClient(() => client.api.withdraw.post(values as WithdrawBody));
}
const isNumber = numberPattern.test(value);
isNumber ? amountInputInst.value?.$el.classList.add("ion-valid") : amountInputInst.value?.$el.classList.add("ion-invalid");
return isNumber;
}
function handleCurrentChange() {
form.value.amount = "";
}
async function onSubmit() {
await safeClient(() => client.api.withdraw.post(form.value));
const toast = await toastController.create({ const toast = await toastController.create({
message: t("withdraw.successMessage"), message: t("withdraw.successMessage"),
duration: 2000, duration: 2000,
@@ -53,7 +38,6 @@ async function onSubmit() {
}); });
await toast.present(); await toast.present();
resetForm();
router.back(); router.back();
} }
</script> </script>
@@ -69,80 +53,127 @@ async function onSubmit() {
</ion-toolbar> </ion-toolbar>
</IonHeader> </IonHeader>
<IonContent :fullscreen="true" class="ion-padding"> <IonContent :fullscreen="true" class="ion-padding">
<div class="flex flex-col gap-5"> <Form
<ion-radio-group v-model="form.assetCode" @ion-change="handleCurrentChange"> :validation-schema="schema"
<ion-label class="text-sm"> :initial-values="initialValues"
{{ t("withdraw.chooseCurrency") }} @submit="onSubmit"
</ion-label> >
<ion-item v-for="item in AssetCodeEnum" :key="item"> <div class="flex flex-col gap-5">
<ion-radio :value="item" justify="space-between"> <div>
{{ item }} <Field name="assetCode">
</ion-radio> <template #default="{ field, value }">
</ion-item> <ion-radio-group v-bind="field" :model-value="value">
</ion-radio-group> <ion-label class="text-sm">
{{ t("withdraw.chooseCurrency") }}
</ion-label>
<ion-item v-for="item in AssetCodeEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
</template>
</Field>
<ErrorMessage name="assetCode" class="text-red-500 text-xs mt-1" />
</div>
<ion-radio-group v-model="form.withdrawMethod"> <div>
<ion-label class="text-sm"> <Field name="withdrawMethod">
{{ t("withdraw.chooseMethod") }} <template #default="{ field, value }">
</ion-label> <ion-radio-group v-bind="field" :model-value="value">
<ion-item v-for="item in WithdrawMethodEnum" :key="item"> <ion-label class="text-sm">
<ion-radio :value="item" justify="space-between"> {{ t("withdraw.chooseMethod") }}
{{ item }} </ion-label>
</ion-radio> <ion-item v-for="item in WithdrawMethodEnum" :key="item">
</ion-item> <ion-radio :value="item" justify="space-between">
</ion-radio-group> {{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
</template>
</Field>
<ErrorMessage name="withdrawMethod" class="text-red-500 text-xs mt-1" />
</div>
<ui-input-label <Field name="withdrawMethod">
ref="amountInputInst" <template #default="{ value: withdrawMethod }">
v-model="form.amount" <div v-if="withdrawMethod === WithdrawMethodEnum.BANK">
:label="t('withdraw.amount')" <Field name="bankAccountId">
:placeholder="t('withdraw.enterAmountMax', { amount: maxAmount })" <template #default="{ field }">
type="number" <ion-select
inputmode="numeric" v-bind="field"
:error-text="t('withdraw.validAmountError')" interface="action-sheet"
:max="maxAmount" toggle-icon=""
@ion-input="validate($event.target.value as string)" label-placement="floating"
@ion-blur="markTouched" :label="t('withdraw.bankAccountId')"
/> :placeholder="t('withdraw.enterBankAccountId')"
>
<ion-select-option v-for="item in bankAccounts" :key="item.id" :value="item.id">
{{ item.bankName }} - **** **** **** {{ item.accountName.slice(-4) }}
</ion-select-option>
</ion-select>
</template>
</Field>
<ErrorMessage name="bankAccountId" class="text-red-500 text-xs mt-1" />
</div>
<ion-select <template v-else-if="withdrawMethod === WithdrawMethodEnum.CRYPTO">
v-if="form.withdrawMethod === WithdrawMethodEnum.BANK" <div>
v-model="form.bankAccountId" <Field name="chain">
interface="action-sheet" <template #default="{ field, value }">
toggle-icon="" <ion-radio-group v-bind="field" :model-value="value">
label-placement="floating" <ion-label class="text-sm">
:label="t('withdraw.bankAccountId')" {{ t("withdraw.chooseChain") }}
:placeholder="t('withdraw.enterBankAccountId')" </ion-label>
> <ion-item v-for="item in ChainEnum" :key="item">
<ion-select-option v-for="item in bankAccounts" :key="item.id" :value="item.id"> <ion-radio :value="item" justify="space-between">
{{ item.bankName }} - **** **** **** {{ item.accountName.slice(-4) }} {{ item }}
</ion-select-option> </ion-radio>
</ion-select> </ion-item>
<template v-else-if="form.withdrawMethod === WithdrawMethodEnum.CRYPTO"> </ion-radio-group>
<ion-radio-group v-model="form.chain"> </template>
<ion-label class="text-sm"> </Field>
{{ t("withdraw.chooseChain") }} <ErrorMessage name="chain" class="text-red-500 text-xs mt-1" />
</ion-label> </div>
<ion-item v-for="item in ChainEnum" :key="item">
<ion-radio :value="item" justify="space-between">
{{ item }}
</ion-radio>
</ion-item>
</ion-radio-group>
<ui-input-label
v-model="form.toAddress"
:label="t('withdraw.cryptoAddress')"
:placeholder="t('withdraw.enterCryptoAddress')"
type="text"
inputmode="text"
:error-text="t('withdraw.validCryptoAddressError')"
/>
</template>
<ion-button expand="block" @click="onSubmit"> <div>
{{ t("withdraw.submit") }} <Field name="toAddress">
</ion-button> <template #default="{ field }">
</div> <ui-input-label
v-bind="field"
:label="t('withdraw.cryptoAddress')"
:placeholder="t('withdraw.enterCryptoAddress')"
type="text"
inputmode="text"
/>
</template>
</Field>
<ErrorMessage name="toAddress" class="text-red-500 text-xs mt-1" />
</div>
</template>
</template>
</Field>
<div>
<Field name="amount">
<template #default="{ field }">
<ui-input-label
v-bind="field"
:label="t('withdraw.amount')"
:placeholder="t('withdraw.enterAmountMax', { amount: maxAmount })"
type="number"
inputmode="numeric"
/>
</template>
</Field>
<ErrorMessage name="amount" class="text-red-500 text-xs mt-1" />
</div>
<ion-button expand="block" type="submit">
{{ t("withdraw.submit") }}
</ion-button>
</div>
</Form>
</IonContent> </IonContent>
</ion-page> </ion-page>
</template> </template>

View File

@@ -0,0 +1,41 @@
import { toTypedSchema } from "@vee-validate/yup";
import * as yup from "yup";
import { WithdrawMethodEnum } from "@/api/enum";
export function createWithdrawSchema(t: (key: string, params?: any) => string, maxAmount: string) {
return toTypedSchema(
yup.object({
assetCode: yup.string().required(t("withdraw.validation.assetCodeRequired")),
amount: yup
.string()
.required(t("withdraw.validation.amountRequired"))
.test("is-number", t("withdraw.validation.amountInvalid"), value => {
return /^\d+(\.\d+)?$/.test(value || "");
})
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), value => {
if (!value || maxAmount === "0") return false;
return parseFloat(value) <= parseFloat(maxAmount);
})
.test("min-amount", t("withdraw.validation.amountTooSmall"), value => {
if (!value) return false;
return parseFloat(value) > 0;
}),
withdrawMethod: yup.string().required(t("withdraw.validation.methodRequired")),
bankAccountId: yup.string().when("withdrawMethod", {
is: WithdrawMethodEnum.BANK,
then: (schema) => schema.required(t("withdraw.validation.bankAccountRequired")),
otherwise: (schema) => schema.optional(),
}),
chain: yup.string().when("withdrawMethod", {
is: WithdrawMethodEnum.CRYPTO,
then: (schema) => schema.required(t("withdraw.validation.chainRequired")),
otherwise: (schema) => schema.optional(),
}),
toAddress: yup.string().when("withdrawMethod", {
is: WithdrawMethodEnum.CRYPTO,
then: (schema) => schema.required(t("withdraw.validation.addressRequired")).min(10, t("withdraw.validation.addressTooShort")),
otherwise: (schema) => schema.optional(),
}),
}),
);
}