aslan-h5/src/views/RechargeAgency/SubSellers.vue
2026-07-09 13:36:36 +08:00

567 lines
13 KiB
Vue

<template>
<div class="sub-sellers-page gradient-background-circles">
<GeneralHeader
:showLanguageList="true"
:title="t('sub_coin_sellers')"
color="black"
style="box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); z-index: 999"
/>
<div class="content">
<div class="balance-card">
<div class="section-title">{{ t('current_balance') }}</div>
<div class="balance-row">
<img src="../../assets/icon/Azizi/coin.png" alt="" height="20" />
<span>{{ currentBalance }}</span>
</div>
</div>
<div class="search-card">
<div class="section-title">{{ t('invite_sub_coin_seller') }}</div>
<div class="search-row">
<div class="search-box">
<img src="../../assets/icon/Azizi/search.png" alt="" />
<input
v-model="searchQuery"
:placeholder="t('enter_coin_seller_id')"
@keyup.enter="searchCoinSeller"
@input="onSearchInput"
dir="ltr"
/>
</div>
<button class="primary-btn compact" :disabled="isSearching" @click="searchCoinSeller">
{{ isSearching ? t('loading') : t('search') }}
</button>
</div>
<div v-if="searchResultProfile" class="user-card">
<div class="profile-main">
<img
:src="searchResultProfile.userAvatar || ''"
alt=""
@error="handleAvatarImageError"
/>
<div class="profile-text">
<div class="profile-name">
{{ searchResultProfile.userNickname || searchResultProfile.name }}
</div>
<div class="profile-id">ID: {{ searchResultProfile.account }}</div>
</div>
</div>
<button class="primary-btn" :disabled="isBinding" @click="bindSubSeller">
{{ isBinding ? t('loading') : t('bind_sub_coin_seller') }}
</button>
</div>
</div>
<div class="list-header">
<div class="section-title">{{ t('sub_coin_seller_list') }}</div>
<button class="text-btn" :disabled="isLoadingList" @click="fetchSubSellers">
{{ t('refresh') }}
</button>
</div>
<div v-if="isLoadingList" class="empty-state">{{ t('loading') }}</div>
<div v-else-if="subSellers.length === 0" class="empty-state">
{{ t('no_sub_coin_sellers') }}
</div>
<div v-else class="seller-list">
<div
v-for="seller in subSellers"
:key="seller.relationId || seller.childUserId"
class="seller-item"
>
<div class="profile-main">
<img
:src="profileOf(seller).userAvatar || ''"
alt=""
@error="handleAvatarImageError"
/>
<div class="profile-text">
<div class="profile-name">
{{ profileOf(seller).userNickname || profileOf(seller).name }}
</div>
<div class="profile-id">ID: {{ profileOf(seller).account }}</div>
<div class="profile-id">{{ t('balance') }}: {{ seller.balance || 0 }}</div>
</div>
</div>
<button class="primary-btn" @click="openTransfer(seller)">
{{ t('transfer') }}
</button>
</div>
</div>
</div>
<maskLayer :maskLayerShow="Boolean(transferTarget)" @click="closeTransfer">
<div class="modal-wrap">
<div v-if="transferTarget" class="transfer-modal" @click.stop>
<div class="modal-title">{{ t('transfer_to_sub_coin_seller') }}</div>
<div class="modal-target">
{{ profileOf(transferTarget).userNickname || profileOf(transferTarget).account }}
</div>
<input
v-model="transferAmount"
class="amount-input"
:placeholder="t('enter_transfer_amount')"
inputmode="numeric"
dir="ltr"
@input="onAmountInput"
@keydown="onKeyDown"
/>
<div class="modal-actions">
<button class="ghost-btn" @click="closeTransfer">{{ t('cancel') }}</button>
<button class="primary-btn" :disabled="!canTransfer" @click="confirmTransfer">
{{ isTransferring ? t('loading') : t('confirm') }}
</button>
</div>
</div>
</div>
</maskLayer>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { setDocumentDirection } from '@/locales/i18n'
import { showError, showSuccess } from '@/utils/toast.js'
import { useDebounce, useThrottle } from '@/utils/useDebounce'
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
import {
addFreightSubSeller,
getFreightBalance,
getFreightSubSellers,
searchDealerFreightUser,
transferFreightSubSeller,
} from '@/api/wallet.js'
import GeneralHeader from '@/components/GeneralHeader.vue'
import maskLayer from '@/components/MaskLayer.vue'
const { t, locale } = useI18n()
locale.value && setDocumentDirection(locale.value)
const currentBalance = ref('0')
const subSellers = ref([])
const isLoadingList = ref(false)
const searchQuery = ref('')
const searchResult = ref(null)
const searchResultAccount = ref('')
const isSearching = ref(false)
const isBinding = ref(false)
const transferTarget = ref(null)
const transferAmount = ref('')
const isTransferring = ref(false)
const searchResultProfile = computed(() => searchResult.value?.userProfile || null)
const positiveIntegerPattern = /^[1-9]\d*$/
const canTransfer = computed(() => {
return (
transferTarget.value &&
positiveIntegerPattern.test(transferAmount.value) &&
Number(transferAmount.value) <= Number(currentBalance.value || 0) &&
!isTransferring.value
)
})
const profileOf = (seller) => seller?.userProfile || {}
const onKeyDown = (event) => {
if (['.', 'e', '+', '-'].includes(event.key)) {
event.preventDefault()
}
}
const onAmountInput = (event) => {
const rawValue = event.target.value.trim()
if (rawValue === '') {
transferAmount.value = ''
event.target.value = ''
return
}
if (!/^\d+$/.test(rawValue)) {
event.target.value = transferAmount.value
return
}
let value = rawValue.replace(/^0+(?=\d)/, '')
if (value === '0') {
value = ''
}
const maxTransferAmount = Math.floor(Number(currentBalance.value || 0))
if (value !== '' && Number(value) > maxTransferAmount) {
value = maxTransferAmount > 0 ? String(maxTransferAmount) : ''
}
event.target.value = value
transferAmount.value = value
}
const onSearchInput = () => {
const account = searchQuery.value.trim()
if (!account || account !== searchResultAccount.value) {
searchResult.value = null
searchResultAccount.value = ''
}
}
const fetchBalance = async () => {
try {
const response = await getFreightBalance()
if (response?.status && response.body !== undefined) {
currentBalance.value = response.body.toString()
}
} catch (error) {
console.error('Failed to fetch freight balance:', error)
}
}
const fetchSubSellers = async () => {
try {
isLoadingList.value = true
const response = await getFreightSubSellers()
subSellers.value = response?.status && response.body ? response.body : []
} catch (error) {
console.error('Failed to fetch sub coin sellers:', error)
showError(error.response?.errorMsg || t('something_went_wrong'))
subSellers.value = []
} finally {
isLoadingList.value = false
}
}
const searchCoinSeller = useDebounce(
async () => {
const account = searchQuery.value.trim()
if (!account || isSearching.value) return
try {
isSearching.value = true
searchResult.value = null
searchResultAccount.value = ''
const response = await searchDealerFreightUser(account)
if (response?.status && response.body && searchQuery.value.trim() === account) {
searchResult.value = response.body
searchResultAccount.value = account
}
} catch (error) {
showError(error.response?.errorMsg || t('something_went_wrong'))
} finally {
isSearching.value = false
}
},
500,
true,
)
const bindSubSeller = async () => {
const userId = searchResultProfile.value?.id
if (!userId || isBinding.value || searchQuery.value.trim() !== searchResultAccount.value) {
searchResult.value = null
searchResultAccount.value = ''
return
}
try {
isBinding.value = true
await addFreightSubSeller({ userId })
showSuccess(t('sub_coin_seller_bound'))
searchQuery.value = ''
searchResult.value = null
searchResultAccount.value = ''
await fetchSubSellers()
} catch (error) {
showError(error.response?.errorMsg || t('something_went_wrong'))
} finally {
isBinding.value = false
}
}
const openTransfer = (seller) => {
transferTarget.value = seller
transferAmount.value = ''
}
const closeTransfer = () => {
transferTarget.value = null
transferAmount.value = ''
}
const confirmTransfer = useThrottle(async () => {
if (!canTransfer.value) return
const acceptUserId = transferTarget.value.childUserId || transferTarget.value.userProfile?.id
const quantity = Number(transferAmount.value)
try {
isTransferring.value = true
const response = await transferFreightSubSeller({ acceptUserId, quantity })
if (response?.status) {
if (response.body !== undefined) {
currentBalance.value = response.body.toString()
}
showSuccess(t('sub_coin_seller_transfer_success'))
closeTransfer()
await fetchSubSellers()
}
} catch (error) {
showError(error.response?.errorMsg || t('something_went_wrong'))
} finally {
isTransferring.value = false
}
}, 2000)
onMounted(() => {
fetchBalance()
fetchSubSellers()
})
</script>
<style scoped>
* {
color: rgba(0, 0, 0, 0.8);
}
.sub-sellers-page {
min-height: 100vh;
}
.content {
padding: 16px;
position: relative;
z-index: 2;
}
.balance-card,
.search-card,
.seller-item {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
font-weight: 800;
margin-bottom: 12px;
}
.balance-row {
display: flex;
align-items: center;
gap: 8px;
color: #ffb627;
font-weight: 800;
}
.balance-row span {
color: #ffb627;
}
.search-row {
display: flex;
align-items: center;
gap: 8px;
}
.search-box {
flex: 1;
min-width: 0;
height: 40px;
border-radius: 24px;
border: 1px solid #ffffff;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
padding: 0 10px;
}
.search-box img {
width: 22px;
aspect-ratio: 1/1;
opacity: 0.6;
}
.search-box input,
.amount-input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-weight: 600;
box-sizing: border-box;
}
.search-box input {
padding: 8px;
}
.user-card,
.seller-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.profile-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.profile-main img {
width: 44px;
aspect-ratio: 1/1;
border-radius: 50%;
object-fit: cover;
flex: 0 0 auto;
}
.profile-text {
min-width: 0;
}
.profile-name {
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-id {
margin-top: 3px;
font-size: 0.9em;
color: rgba(0, 0, 0, 0.45);
font-weight: 600;
}
.primary-btn,
.ghost-btn,
.text-btn {
border: none;
font-weight: 800;
cursor: pointer;
}
.primary-btn {
flex: 0 0 auto;
border-radius: 24px;
padding: 9px 14px;
background: linear-gradient(135deg, #bb92ff 2.82%, #8b45ff 99.15%);
color: white;
}
.primary-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.compact {
min-width: 76px;
}
.text-btn {
background: transparent;
color: #8b45ff;
}
.ghost-btn {
border-radius: 24px;
padding: 9px 14px;
background: #f3f4f6;
color: rgba(0, 0, 0, 0.55);
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.seller-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.seller-item {
margin-bottom: 0;
}
.empty-state {
padding: 24px 12px;
text-align: center;
color: rgba(0, 0, 0, 0.45);
font-weight: 700;
}
.modal-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.transfer-modal {
width: 82%;
border-radius: 12px;
background: #ffffff;
padding: 20px;
}
.modal-title {
text-align: center;
font-weight: 900;
margin-bottom: 10px;
}
.modal-target {
text-align: center;
color: rgba(0, 0, 0, 0.5);
font-weight: 700;
margin-bottom: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount-input {
height: 44px;
border-radius: 12px;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.25);
padding: 0 14px;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
align-items: center;
justify-content: space-around;
gap: 12px;
}
@media screen and (max-width: 360px) {
* {
font-size: 10px;
}
}
@media screen and (min-width: 360px) {
* {
font-size: 12px;
}
}
@media screen and (min-width: 768px) {
* {
font-size: 14px;
}
}
</style>