365 lines
13 KiB
Vue
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>
|