Files
hi-partner/src/pages/UserCenter/components/UserInfo/components/WalletDialog.vue
T
sepakeloudest 104e04a8cc
site-dist-deploy / build-and-deploy (push) Failing after 11m50s
提现记录和退费数据展示
2026-06-18 11:23:09 +08:00

365 lines
13 KiB
Vue

<template>
<Dialog v-model:open="open">
<DialogContent
class="gap-0 rounded-[40px]! border-none bg-[#111111] px-12 py-10 text-white shadow-2xl! ring-0! outline-hidden! sm:max-w-[500px]"
>
<DialogHeader>
<DialogTitle class="text-left text-2xl font-bold tracking-tight text-white"
>佣金提现</DialogTitle
>
</DialogHeader>
<div class="my-4 text-left text-sm font-medium text-white/30">
{{
values.type === 'USDT(TRC20)'
? '提现至您的数字钱包,手续费1USDT,该费率由支付平台收取'
: '该提现方式需5%手续费,该费率由支付平台收取'
}}
</div>
<form @submit="onSubmit" class="space-y-[20px]">
<FormField v-slot="{ componentField }" name="type">
<FormItem>
<FormLabel class="block text-sm font-bold text-white">提现方式</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger
class="!h-[46px] w-full rounded-[32px] border-[4px] border-[#ADFF5B] bg-transparent px-6 text-sm font-medium text-[#ADFF5B] outline-hidden! focus:ring-0! [&_svg]:size-6 [&_svg]:text-[#ADFF5B] [&_svg]:opacity-100"
>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent class="rounded-2xl border-white/10 bg-[#222222] text-white">
<SelectItem
v-for="t in ACCOUNT_TYPE"
:key="t"
:value="t"
class="py-3 text-sm focus:bg-[#ADFF5B] focus:text-black"
>
{{ t }}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
</FormField>
<FormField v-if="values.type === 'USDT(TRC20)'" v-slot="{ componentField }" name="account">
<FormItem>
<FormLabel class="block text-sm font-bold text-white">提现地址</FormLabel>
<FormControl>
<Input
v-bind="componentField"
class="h-[46px] rounded-[32px] border-none bg-[#222222] px-6 text-sm text-white transition-colors placeholder:text-white/20 focus:bg-[#2a2a2a]"
placeholder="输入地址"
/>
</FormControl>
<FormMessage class="ml-4 text-red-400" />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="money">
<FormItem>
<FormLabel class="block text-sm font-bold text-white">提现金额</FormLabel>
<div
class="group relative flex h-[46px] items-center overflow-hidden rounded-[32px] bg-[#ADFF5B] p-[3px]"
>
<div class="flex h-full items-center px-6 text-xl font-bold text-black">
{{ values.type === 'USDT(TRC20)' ? '₮' : '$' }}
</div>
<FormControl>
<Input
v-bind="componentField"
inputmode="decimal"
@keypress="
(e: KeyboardEvent) => {
if (!/[\d.]/.test(e.key)) {
e.preventDefault()
}
if (e.key === '.' && (e.target as HTMLInputElement).value.includes('.')) {
e.preventDefault()
}
}
"
@input="
(e: any) => {
let val = e.target.value
// 1. 只允许数字和小数点
val = val.replace(/[^\d.]/g, '')
// 2. 只允许一个小数点
const parts = val.split('.')
if (parts.length > 2) {
val = parts[0] + '.' + parts.slice(1).join('')
}
// 3. 强制限制两位小数
if (parts[1] && parts[1].length > 2) {
val = parts[0] + '.' + parts[1].slice(0, 2)
}
// 4. 关键:直接同步回 DOM,防止拼音输入暂留在框内
e.target.value = val
setFieldValue('money', val)
}
"
class="h-full flex-1 rounded-[28px]! border-none bg-[#222222] px-4 text-center text-sm font-bold text-white placeholder:text-white/20 focus:ring-0! focus-visible:ring-0!"
:placeholder="values.type === 'USDT(TRC20)' ? '不小于30USDT' : '不小于30$'"
/>
</FormControl>
<div class="flex h-full items-center px-6 text-sm font-bold text-black">
{{ values.type === 'USDT(TRC20)' ? 'USDT' : '$' }}
</div>
</div>
<FormMessage class="ml-4 text-red-400" />
</FormItem>
</FormField>
<FormField v-if="values.type !== 'USDT(TRC20)'" name="avatar">
<FormItem>
<FormLabel class="mb-4 block text-sm font-bold text-white">收款码</FormLabel>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="onFileChange"
/>
<div
class="group relative flex cursor-pointer flex-col items-center justify-center overflow-hidden rounded-[32px] border-2 border-dashed border-white/10 bg-[#222222] transition-all hover:border-[#ADFF5B]/50 hover:bg-[#2a2a2a]"
:class="[values.avatar ? 'h-auto min-h-[160px] p-2' : 'p-10']"
@click="fileInputRef?.click()"
>
<template v-if="values.avatar">
<img
:src="receiptPreviewUrl || values.avatar"
class="max-h-[300px] w-full rounded-2xl object-contain"
/>
<div
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
>
<span class="text-sm font-bold text-white">点击更换图片</span>
</div>
</template>
<template v-else>
<Upload class="mb-3 h-12 w-12 text-white/20" />
<span class="text-base font-medium text-white/30">点击上传高清收款二维码</span>
</template>
<div
v-if="isUploadingReceipt"
class="absolute inset-0 flex items-center justify-center bg-black/70"
>
<Loader2 class="mr-2 h-6 w-6 animate-spin text-[#ADFF5B]" />
<span class="text-sm font-bold text-white">上传中</span>
</div>
</div>
<FormMessage class="ml-4 text-red-400" />
</FormItem>
</FormField>
<div class="flex justify-center pt-2">
<Button
type="submit"
:disabled="isPending || isUploadingReceipt"
class="h-[30px] w-[100px] rounded-full bg-[#ADFF5B] text-sm font-black text-black shadow-[0_10px_30px_rgba(173,255,91,0.3)] transition-all hover:scale-105 hover:bg-[#9ded4e] active:scale-95 disabled:opacity-50"
>
<Loader2 v-if="isPending" class="mr-2 h-7 w-7 animate-spin" />
确定
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from 'vee-validate'
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { toast } from 'vue-sonner'
import { Upload, Loader2 } from 'lucide-vue-next'
// Shadcn UI 组件
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// 业务 API
import request from '@/utils/request'
const props = defineProps<{ commission: number }>()
const emit = defineEmits(['confirm'])
const open = ref(false)
const isPending = ref(false) // 手动管理加载状态
const isUploadingReceipt = ref(false)
const receiptPreviewUrl = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const ACCOUNT_TYPE = ['USDT(TRC20)', '微信', '支付宝'] as const
const RECEIPT_UPLOAD_BIZ_TYPE = 'app-package'
const WITHDRAW_METHOD = {
支付宝: 1,
微信: 2,
'USDT(TRC20)': 3,
} as const
type FileUploadResponse =
| string
| {
url?: string
file_url?: string
full_url?: string
path?: string
uri?: string
file?: string
src?: string
}
const revokeReceiptPreview = () => {
if (receiptPreviewUrl.value) {
URL.revokeObjectURL(receiptPreviewUrl.value)
receiptPreviewUrl.value = ''
}
}
const getUploadedFileUrl = (data: FileUploadResponse) => {
if (typeof data === 'string') return data
return (
data.url ||
data.file_url ||
data.full_url ||
data.path ||
data.uri ||
data.file ||
data.src ||
''
)
}
const uploadReceiptCode = async (file: File) => {
const formData = new FormData()
formData.append('biz_type', RECEIPT_UPLOAD_BIZ_TYPE)
formData.append('file', file, file.name)
const data = await request.post<FormData, FileUploadResponse>(
'/api/v1/public/file/upload',
formData,
)
return getUploadedFileUrl(data)
}
const onFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('请上传图片文件')
return
}
try {
isUploadingReceipt.value = true
const uploadedUrl = await uploadReceiptCode(file)
if (!uploadedUrl) {
toast.error('上传失败,请重试')
return
}
revokeReceiptPreview()
receiptPreviewUrl.value = URL.createObjectURL(file)
setFieldValue('avatar', uploadedUrl)
} catch (err) {
console.error('收款码上传失败:', err)
toast.error('上传失败,请重试')
} finally {
isUploadingReceipt.value = false
// 重置 input,允许重新选择同一张图
if (e.target) {
;(e.target as HTMLInputElement).value = ''
}
}
}
// --- 表单验证 Schema ---
const formSchema = toTypedSchema(
z
.object({
type: z.enum(ACCOUNT_TYPE),
account: z.string().optional(),
money: z.coerce
.string()
.min(1, '金额不能为空')
.regex(/^\d+(\.\d{1,2})?$/, '格式错误,最多支持两位小数'),
avatar: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'USDT(TRC20)' && !data.account) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: '地址不能为空', path: ['account'] })
} else if (data.type !== 'USDT(TRC20)' && !data.avatar) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: '请上传收款码', path: ['avatar'] })
}
}),
)
const { handleSubmit, resetForm, values, setFieldValue } = useForm({
validationSchema: formSchema,
initialValues: { type: 'USDT(TRC20)', account: '', money: '', avatar: '' },
})
// --- 暴露给父组件的方法 ---
const show = () => {
revokeReceiptPreview()
resetForm()
open.value = true
}
defineExpose({ show })
// --- 提交逻辑 ---
const onSubmit = handleSubmit(async (val) => {
const amount = parseFloat(val.money)
// 1. 基础校验
const minAmount = 30
if (amount < minAmount)
return toast.error(`金额不能小于${minAmount}${val.type === 'USDT(TRC20)' ? 'USDT' : '$'}`)
try {
isPending.value = true
// 2. 获取最新佣金进行校验
const userInfo = await request.get<any>('/api/v1/public/user/info')
const latestCommission = userInfo?.commission || 0
if (Math.round(amount * 100) > latestCommission) {
toast.error('佣金不足')
return
}
// 3. 执行提现请求
await request.post('/api/v1/public/user/commission_withdraw', {
amount: Math.round(amount * 100),
method: WITHDRAW_METHOD[val.type],
account: val.type === 'USDT(TRC20)' ? val.account : '',
qr_code_url: val.type === 'USDT(TRC20)' ? '' : val.avatar,
})
// 4. 成功后处理
toast.success('提现申请已提交,请等待审核')
open.value = false
emit('confirm') // 触发父组件刷新 info 接口
} catch (error) {
console.error('提现失败1:', error)
toast.error('操作失败,请重试')
} finally {
isPending.value = false
}
})
</script>