Переглянути джерело

Enhance wallet withdrawal functionality and UI

- Added a new API method to fetch withdrawal information, including minimum withdrawal amounts and fee configurations.
- Updated the withdrawal application page to incorporate loading states and improved error handling during data fetching.
- Refactored input validation for withdrawal amounts to ensure compliance with minimum requirements.
- Enhanced user feedback with formatted currency display and dynamic exchange rate calculations.
0es 1 місяць тому
батько
коміт
84158782f8

+ 14 - 1
app/api/wallet.ts

@@ -4,7 +4,7 @@
  */
 
 import { http } from '~/utils/request'
-import type { NextVOWalletRecordVo, ResultVOString, WalletRechargeConfigVO, WalletRechargeOrderStateVo, WalletRecordDTO, WalletWithdrawRealNameAuthDTO, WalletWithdrawRealInfoAuthApplyVo, WalletWithdrawRealInfoAuthStatusVo } from '~/types/api'
+import type { NextVOWalletRecordVo, ResultVOString, WalletRechargeConfigVO, WalletRechargeOrderStateVo, WalletRecordDTO, WalletWithdrawInfoVo, WalletWithdrawRealNameAuthDTO, WalletWithdrawRealInfoAuthApplyVo, WalletWithdrawRealInfoAuthStatusVo } from '~/types/api'
 
 export const walletApi = {
   /**
@@ -73,4 +73,17 @@ export const walletApi = {
   getWithdrawRealNameAuthStatus() {
     return http.post<WalletWithdrawRealInfoAuthStatusVo>('/wallet/withdraw/getRealNameAuthStatus')
   },
+
+  /**
+   * 获取金豆提现信息
+   * 对应后端接口:POST /wallet/withdraw/info
+   * 按后端要求使用 application/x-www-form-urlencoded
+   */
+  getWithdrawInfo() {
+    return http.post<WalletWithdrawInfoVo>('/wallet/withdraw/info', undefined, {
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+      },
+    })
+  },
 }

+ 158 - 16
app/pages/wallet/withdraw/apply.vue

@@ -1,9 +1,12 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, watchEffect } from 'vue'
+import { showToast } from 'vant'
 import type { NativeSafeArea } from '~/types/native'
-import { callNativeScheme } from '~/utils/helpers'
+import { callNativeScheme, idrFormat } from '~/utils/helpers'
 import { walletApi } from '~/api/wallet'
+import type { WalletWithdrawInfoVo } from '~/types/api'
 import beanPng from '~/assets/images/common/bean.png'
+import { useBaseConstsStore } from '~/stores/baseConsts'
 
 definePageMeta({
   auth: true,
@@ -16,8 +19,9 @@ const router = useRouter()
 
 // Check auth status on mount
 const checkingAuth = ref(true)
+const loadingInfo = ref(true)
 
-const checkAuthStatus = async () => {
+const checkAuthStatus = async (): Promise<boolean> => {
   try {
     checkingAuth.value = true
     const status = await walletApi.getWithdrawRealNameAuthStatus()
@@ -25,13 +29,15 @@ const checkAuthStatus = async () => {
     // If both are not approved (state=2), redirect to auth page
     if (!status || status.idCardState !== 2 || status.bankCardState !== 2) {
       await router.replace('/wallet/withdraw/auth')
-      return
+      return false
     }
+    return true
   }
   catch (error) {
     console.error('Failed to check auth status:', error)
     // Redirect to auth page on error
     await router.replace('/wallet/withdraw/auth')
+    return false
   }
   finally {
     checkingAuth.value = false
@@ -39,19 +45,89 @@ const checkAuthStatus = async () => {
 }
 
 onMounted(() => {
-  checkAuthStatus()
+  (async () => {
+    const ok = await checkAuthStatus()
+    if (!ok) return
+
+    try {
+      loadingInfo.value = true
+      const info = await walletApi.getWithdrawInfo()
+      applyWithdrawInfo(info)
+    }
+    catch (error) {
+      console.error('Failed to fetch withdraw info:', error)
+      showToast({ message: 'Failed to fetch withdraw info' })
+    }
+    finally {
+      loadingInfo.value = false
+    }
+  })()
 })
 
-// Static demo data based on Figma. Replace with real wallet data later.
-const balanceBeans = ref(20000)
-const balanceCurrency = ref('IDR')
-const balanceCurrencyAmount = ref('20000.000')
+const withdrawInfo = ref<WalletWithdrawInfoVo | null>(null)
+
+const balanceBeans = ref(0)
+const balanceCurrencyAmount = computed(() => {
+  if (!Number.isFinite(balanceBeans.value) || balanceBeans.value <= 0) return idrFormat(0)
+  return idrFormat(balanceBeans.value * exchangeRate.value)
+})
 
 const fromBeans = ref<string>('')
 
 const exchangeRate = ref<number>(1)
 const minimumWithdrawalAmount = ref<number>(0)
 const withdrawalFeePercent = ref<number>(0)
+const withdrawalFeeAmount = ref<number>(0)
+const withdrawFeeType = ref<0 | 1>(1)
+
+const baseConstsStore = useBaseConstsStore()
+
+const beanToIdrExchange = computed(() => {
+  const list = baseConstsStore.config?.commonCoinExchangeConsts ?? []
+  const item = list.find((it) => {
+    const obj = it as Record<string, number>
+    return typeof obj.bean === 'number' && typeof obj.IDR === 'number'
+  }) as Record<string, number> | undefined
+
+  const bean = item?.bean ?? 1
+  const idr = item?.IDR ?? 1
+  const rate = bean > 0 ? idr / bean : 1
+
+  return { bean, idr, rate }
+})
+
+watchEffect(() => {
+  const next = beanToIdrExchange.value.rate
+  if (Number.isFinite(next) && next > 0) {
+    exchangeRate.value = next
+  }
+})
+
+const canInputBeans = computed(() => {
+  const max = balanceBeans.value
+  const min = minimumWithdrawalAmount.value
+  return Number.isFinite(max) && Number.isFinite(min) && max > 0 && max >= min
+})
+
+const beansPlaceholder = computed(() => {
+  return canInputBeans.value ? 'Please fill in' : '不满足最低提现金额'
+})
+
+const applyWithdrawInfo = (info: WalletWithdrawInfoVo) => {
+  withdrawInfo.value = info
+
+  balanceBeans.value = Number(info.availableBeanAmount) || 0
+  minimumWithdrawalAmount.value = Number(info.withdrawMinBeanAmount) || 0
+
+  withdrawFeeType.value = (info.config?.feeType ?? 1) as 0 | 1
+  withdrawalFeeAmount.value = Number(info.config?.feeAmount) || 0
+  withdrawalFeePercent.value = Number(info.config?.feeRate) || 0
+
+  // If balance is below minimum, clear any existing input
+  if (!canInputBeans.value) {
+    fromBeans.value = ''
+  }
+}
 
 const nativeSafeArea = useState<NativeSafeArea>('native-safe-area')
 const containerStyle = computed<Record<string, string>>(() => ({ paddingTop: `${nativeSafeArea.value.top}px` }))
@@ -60,12 +136,17 @@ const bottomBarStyle = computed<Record<string, string>>(() => ({ paddingBottom:
 
 const toCurrencyAmount = computed(() => {
   const n = Number(fromBeans.value)
-  if (!fromBeans.value || !Number.isFinite(n) || n <= 0) return '0.000'
-  return (n * exchangeRate.value).toFixed(3)
+  if (!fromBeans.value || !Number.isFinite(n) || n <= 0) return idrFormat(0)
+  return idrFormat(n * exchangeRate.value)
 })
 
 const tipText = computed(() => {
-  return `Exchange rate from Gami Beans to IDR: ${exchangeRate.value},Mininum withdrawal amount:${minimumWithdrawalAmount.value},withdrawal fee: ${withdrawalFeePercent.value}%.`
+  const minBeans = minimumWithdrawalAmount.value
+  const feeText = withdrawFeeType.value === 0
+    ? `withdrawal fee: ${idrFormat(withdrawalFeeAmount.value, { withSymbol: true })}`
+    : `withdrawal fee: ${withdrawalFeePercent.value}%`
+  const { bean, idr } = beanToIdrExchange.value
+  return `Exchange rate from Beans to IDR: ${bean}:${idr}, Minimum withdrawal amount: ${minBeans} Beans, ${feeText}.`
 })
 
 const onBack = () => {
@@ -73,8 +154,61 @@ const onBack = () => {
 }
 
 const onWithdrawAll = () => {
+  if (!canInputBeans.value) return
   fromBeans.value = String(balanceBeans.value)
 }
+
+const normalizeBeansInput = () => {
+  if (!canInputBeans.value) {
+    fromBeans.value = ''
+    return
+  }
+
+  const raw = fromBeans.value
+  const n = Number(raw)
+  if (!raw || !Number.isFinite(n)) {
+    fromBeans.value = ''
+    return
+  }
+
+  const max = Math.floor(balanceBeans.value)
+  const min = Math.floor(minimumWithdrawalAmount.value)
+
+  let v = Math.floor(n)
+  if (v < min) v = min
+  if (v > max) v = max
+
+  fromBeans.value = String(v)
+}
+
+const validateBeforeSubmit = (): boolean => {
+  if (!canInputBeans.value) {
+    showToast({ message: '不满足最低提现金额' })
+    return false
+  }
+
+  normalizeBeansInput()
+  const v = Number(fromBeans.value)
+  const min = minimumWithdrawalAmount.value
+  const max = balanceBeans.value
+
+  if (!fromBeans.value || !Number.isFinite(v) || v <= 0) {
+    showToast({ message: 'Please fill in' })
+    return false
+  }
+  if (v < min || v > max) {
+    // Should have been normalized, but keep a safe guard
+    showToast({ message: 'Invalid amount' })
+    return false
+  }
+
+  return true
+}
+
+const onSubmit = () => {
+  if (!validateBeforeSubmit()) return
+  // Submit API is not provided in the spec; only do local validation for now.
+}
 </script>
 
 <template>
@@ -90,7 +224,7 @@ const onWithdrawAll = () => {
 
     <!-- Loading state -->
     <div
-      v-if="checkingAuth"
+      v-if="checkingAuth || loadingInfo"
       class="withdraw-apply-loading"
     >
       <p>{{ t('common.loading') }}</p>
@@ -117,7 +251,7 @@ const onWithdrawAll = () => {
 
           <div class="withdraw-apply-balance__approx">
             <span class="withdraw-apply-balance__approx-sign">≈</span>
-            <span class="withdraw-apply-balance__approx-currency">{{ balanceCurrency }}</span>
+            <span class="withdraw-apply-balance__approx-currency">Rp</span>
             <span class="withdraw-apply-balance__approx-amount">{{ balanceCurrencyAmount }}</span>
           </div>
         </div>
@@ -150,7 +284,9 @@ const onWithdrawAll = () => {
               type="number"
               inputmode="numeric"
               autocomplete="off"
-              placeholder="Please fill in"
+              :disabled="!canInputBeans"
+              :placeholder="beansPlaceholder"
+              @blur="normalizeBeansInput"
             >
           </div>
         </div>
@@ -165,7 +301,7 @@ const onWithdrawAll = () => {
           </p>
 
           <div class="withdraw-apply-card__to">
-            <span class="withdraw-apply-card__to-currency">IDR</span>
+            <span class="withdraw-apply-card__to-currency">Rp</span>
             <span class="withdraw-apply-card__to-amount">{{ toCurrencyAmount }}</span>
           </div>
         </div>
@@ -184,6 +320,8 @@ const onWithdrawAll = () => {
       <button
         type="button"
         class="withdraw-apply-bottom__btn"
+        :disabled="!canInputBeans"
+        @click="onSubmit"
       >
         With Draw
       </button>
@@ -395,5 +533,9 @@ const onWithdrawAll = () => {
   font-weight: 600;
   font-size: 16px;
   line-height: 17px;
+
+  &:disabled {
+    opacity: 0.5;
+  }
 }
 </style>

+ 9 - 0
app/types/api/common.ts

@@ -16,6 +16,15 @@ export interface CommonAreaConstItem {
 
 export interface BaseConstsConfig {
   commonAreaConsts?: CommonAreaConstItem[]
+  /**
+   * 汇率配置(示例):
+   * [
+   *   { "IDR": 1000, "goldCoin": 1 },
+   *   { "IDR": 1, "bean": 1 },
+   *   { "diamond": 1, "IDR": 1 }
+   * ]
+   */
+  commonCoinExchangeConsts?: Array<Record<string, number>>
   [key: string]: unknown
 }
 

+ 25 - 0
app/types/api/wallet.ts

@@ -160,3 +160,28 @@ export interface WalletWithdrawRealInfoAuthStatusVo {
   /** 银行卡信息-审核备注 */
   bankReviewNotes?: string
 }
+
+// Wallet withdraw info
+// 对应后端:POST /wallet/withdraw/info
+
+export interface BaseWithdrawConfigBO {
+  /** 币种 */
+  currency: string
+  /** 最小提现金额(法币,如 IDR) */
+  minnum: number
+  /** 固定手续费 */
+  feeAmount: number
+  /** 手续费率 % */
+  feeRate: number
+  /** 0:固定手续费,1:手续费率 */
+  feeType: 0 | 1
+}
+
+export interface WalletWithdrawInfoVo {
+  /** 金豆可用余额 */
+  availableBeanAmount: number
+  /** 提现最小金豆数量 */
+  withdrawMinBeanAmount: number
+  /** 配置 */
+  config: BaseWithdrawConfigBO
+}

+ 44 - 0
app/utils/helpers.ts

@@ -4,6 +4,50 @@ import { isIOS } from '~/utils/ua'
 export const formatNumber = (value: number) =>
   new Intl.NumberFormat().format(value)
 
+export interface IdrFormatOptions {
+  /** Format with currency symbol (Rp) */
+  withSymbol?: boolean
+  /** Minimum fraction digits */
+  minimumFractionDigits?: number
+  /** Maximum fraction digits */
+  maximumFractionDigits?: number
+  /** Fallback string for invalid input */
+  fallback?: string
+}
+
+export const idrFormat = (
+  value: number | string | null | undefined,
+  options: IdrFormatOptions = {},
+): string => {
+  const {
+    withSymbol = false,
+    minimumFractionDigits = 0,
+    maximumFractionDigits = 0,
+    fallback = '0',
+  } = options
+
+  const n = typeof value === 'number'
+    ? value
+    : Number(String(value ?? '').replace(/,/g, '').trim())
+
+  if (!Number.isFinite(n)) return fallback
+
+  if (withSymbol) {
+    return new Intl.NumberFormat('id-ID', {
+      style: 'currency',
+      currency: 'IDR',
+      minimumFractionDigits,
+      maximumFractionDigits,
+    }).format(n)
+  }
+
+  return new Intl.NumberFormat('id-ID', {
+    style: 'decimal',
+    minimumFractionDigits,
+    maximumFractionDigits,
+  }).format(n)
+}
+
 export const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
 
 export const buildPng = async (element: HTMLElement) => {