Files
thebet365/apps/player/src/views/MobileRechargeHistoryView.vue

844 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { formatMoney } from '../utils/localeDisplay';
import {
auditActionTone,
auditActorSecondary,
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../utils/depositAuditDisplay';
const router = useRouter();
const { t, locale } = useI18n();
interface DepositAuditLog {
id: string;
action: string;
actorType: string;
statusBefore: string | null;
statusAfter: string;
amount: string | null;
approvedAmount: string | null;
remark: string | null;
createdAt: string;
}
interface DepositOrder {
id: string;
orderNo: string;
paymentMethodId?: string;
methodType: string;
amount: string;
status: string;
approvedAmount: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
auditLogs?: DepositAuditLog[];
}
const items = ref<DepositOrder[]>([]);
const loading = ref(false);
const initialLoading = ref(true);
const page = ref(1);
const total = ref(0);
const hasMore = ref(true);
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const selectedOrder = ref<DepositOrder | null>(null);
async function fetchOrders(p = 1) {
if (loading.value) return;
loading.value = true;
try {
const { data } = await api.get('/player/deposit-orders', { params: { page: p } });
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
const newItems = result.items ?? [];
if (p === 1) {
items.value = newItems;
} else {
items.value = [...items.value, ...newItems];
}
total.value = result.total ?? 0;
const pageSize = result.pageSize ?? 20;
hasMore.value = newItems.length >= pageSize && items.value.length < total.value;
page.value = p;
} catch { /* */ } finally {
loading.value = false;
initialLoading.value = false;
}
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await fetchOrders(1); },
});
onMounted(() => {
fetchOrders(1);
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
fetchOrders(page.value + 1);
}
},
{ rootMargin: '200px' },
);
if (sentinel.value) observer.observe(sentinel.value);
});
onUnmounted(() => {
observer?.disconnect();
});
function statusClass(s: string) {
if (s === 'APPROVED') return 'status-approved';
if (s === 'REJECTED') return 'status-rejected';
return 'status-pending';
}
function statusLabel(s: string) {
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
function goBack() {
router.push('/wallet');
}
function goRecharge() {
router.push('/wallet/recharge');
}
function openDetail(order: DepositOrder) {
selectedOrder.value = order;
}
function closeDetail() {
selectedOrder.value = null;
}
function reapply(order: DepositOrder) {
const query: Record<string, string> = {
orderId: order.id,
methodType: order.methodType,
amount: order.amount,
};
if (order.paymentMethodId) {
query.methodId = order.paymentMethodId;
}
router.push({ path: '/wallet/recharge', query });
}
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
/** Backend sets remark = rejectReason on reject; show one line only. */
function orderNote(order: DepositOrder): { label: string; text: string } | null {
const rejectReason = normalizeText(order.rejectReason);
const remark = normalizeText(order.remark);
if (order.status === 'REJECTED') {
const text = rejectReason || remark;
if (!text) return null;
return { label: t('recharge.reject_reason'), text };
}
if (remark) {
return { label: t('recharge.remark'), text: remark };
}
return null;
}
function orderNoteLine(order: DepositOrder) {
const note = orderNote(order);
return note ? `${note.label}: ${note.text}` : null;
}
function auditActionLabel(action: string) {
const key = `recharge.audit_${action.toLowerCase()}` as const;
const translated = t(key);
return translated !== key ? translated : action;
}
function auditStepClass(action: string) {
return `audit-step--${auditActionTone(action)}`;
}
function formatAuditTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatOrderTime(iso: string) {
return new Date(iso).toLocaleString();
}
function auditRemarkForTimeline(log: DepositAuditLog, order: DepositOrder) {
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
return null;
}
return formatDepositAuditRemark(log, t);
}
function auditLogsForDisplay(order: DepositOrder) {
return [...(order.auditLogs ?? [])]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((log) => ({
log,
actor: auditActorSecondary(log, t),
remark: auditRemarkForTimeline(log, order),
}));
}
function auditNoteLine(text: string) {
return `${t('recharge.audit_remark_label')}: ${text}`;
}
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
if (remark.text === t('wallet.remark_deposit_revoke_generic')) {
return remark.text;
}
return auditNoteLine(remark.text);
}
function auditStepCount(order: DepositOrder) {
return order.auditLogs?.length ?? 0;
}
</script>
<template>
<div class="recharge-history-page">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.history_title') }}</h2>
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
</div>
<div
class="pull-indicator"
:style="{ height: `${pullDistance}px`, opacity: Math.min(pullDistance / 48, 1) }"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="initialLoading && loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-if="!items.length" class="empty">{{ t('recharge.no_orders') }}</div>
<div v-else class="order-list">
<div
v-for="order in items"
:key="order.id"
class="order-card"
:class="{ rejected: order.status === 'REJECTED' }"
role="button"
tabindex="0"
@click="openDetail(order)"
@keydown.enter="openDetail(order)"
>
<div class="order-header">
<span class="method-badge" :class="order.methodType === 'BANK' ? 'bank' : 'usdt'">{{ order.methodType }}</span>
<span :class="['status-badge', statusClass(order.status)]">{{ statusLabel(order.status) }}</span>
</div>
<div class="order-body">
<div class="order-amount">{{ formatMoney(order.amount, locale) }}</div>
<div v-if="order.approvedAmount && order.approvedAmount !== order.amount" class="approved-amount">
{{ t('recharge.credited') }}: {{ formatMoney(order.approvedAmount, locale) }}
</div>
<div class="order-info-row">
<span class="info-label">{{ order.paymentMethodName || '-' }}</span>
</div>
<div class="order-times">
<div class="time-row">
<span class="time-label">{{ t('recharge.apply_time') }}</span>
<span class="time-value">{{ formatOrderTime(order.createdAt) }}</span>
</div>
<div v-if="order.reviewedAt" class="time-row">
<span class="time-label">{{ t('recharge.review_time') }}</span>
<span class="time-value">{{ formatOrderTime(order.reviewedAt) }}</span>
</div>
</div>
<div
v-if="orderNoteLine(order)"
:class="order.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
>
{{ orderNoteLine(order) }}
</div>
<div v-if="auditStepCount(order)" class="card-detail-hint">
<span class="card-detail-summary">
{{ t('recharge.audit_summary', { count: auditStepCount(order) }) }}
</span>
<span class="card-detail-link">{{ t('recharge.view_detail') }} </span>
</div>
<button
v-else
type="button"
class="card-detail-link-only"
@click.stop="openDetail(order)"
>
{{ t('recharge.view_detail') }}
</button>
</div>
</div>
</div>
<div ref="sentinel" class="sentinel" />
<div v-if="loading && items.length > 0" class="load-more-spinner">
<GoldSpinner :size="24" />
</div>
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
{{ t('common.no_more') }}
</div>
</template>
<Teleport to="body">
<div v-if="selectedOrder" class="detail-overlay" @click.self="closeDetail">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.order_detail')">
<button type="button" class="detail-close" :aria-label="t('common.close')" @click="closeDetail"></button>
<h3 class="detail-title">{{ t('recharge.order_detail') }}</h3>
<div class="detail-summary">
<div class="detail-summary-head">
<span class="method-badge" :class="selectedOrder.methodType === 'BANK' ? 'bank' : 'usdt'">
{{ selectedOrder.methodType }}
</span>
<span :class="['status-badge', statusClass(selectedOrder.status)]">
{{ statusLabel(selectedOrder.status) }}
</span>
</div>
<div class="detail-amount">{{ formatMoney(selectedOrder.amount, locale) }}</div>
<div
v-if="selectedOrder.approvedAmount && selectedOrder.approvedAmount !== selectedOrder.amount"
class="approved-amount"
>
{{ t('recharge.credited') }}: {{ formatMoney(selectedOrder.approvedAmount, locale) }}
</div>
<div class="detail-method-name">{{ selectedOrder.paymentMethodName || '-' }}</div>
<div class="detail-row">
<span class="detail-label">{{ t('recharge.apply_time') }}</span>
<span class="detail-value">{{ formatOrderTime(selectedOrder.createdAt) }}</span>
</div>
<div v-if="selectedOrder.reviewedAt" class="detail-row">
<span class="detail-label">{{ t('recharge.review_time') }}</span>
<span class="detail-value">{{ formatOrderTime(selectedOrder.reviewedAt) }}</span>
</div>
<div
v-if="orderNoteLine(selectedOrder)"
:class="selectedOrder.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
>
{{ orderNoteLine(selectedOrder) }}
</div>
</div>
<div v-if="selectedOrder.auditLogs?.length" class="detail-audit">
<h4 class="detail-audit-title">{{ t('recharge.audit_title') }}</h4>
<div class="audit-track">
<div
v-for="(entry, logIdx) in auditLogsForDisplay(selectedOrder)"
:key="entry.log.id"
class="audit-step"
:class="auditStepClass(entry.log.action)"
>
<div class="audit-step-rail" aria-hidden="true">
<span class="audit-dot" />
<span v-if="logIdx < auditLogsForDisplay(selectedOrder).length - 1" class="audit-line" />
</div>
<div class="audit-step-body">
<div class="audit-step-head">
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
</div>
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
<p
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
class="audit-step-credited"
>
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
</p>
<div
v-if="entry.remark?.kind === 'reject'"
class="audit-step-box audit-step-box--reject"
>
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
</div>
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
{{ auditNoteDisplayText(entry.remark) }}
</p>
</div>
</div>
</div>
</div>
<button
v-if="selectedOrder.status === 'REJECTED'"
type="button"
class="modal-reapply-btn btn-gold-outline"
@click.stop="reapply(selectedOrder)"
>
{{ t('recharge.reapply') }}
</button>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.recharge-history-page { padding: 0 16px 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; color: #1A1A2E; }
.back-btn { background: none; border: none; color: #003D6B; font-size: 24px; cursor: pointer; padding: 0 8px; }
.recharge-btn { background: none; border: none; color: #003D6B; font-size: 13px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.empty { text-align: center; color: #6B7280; padding: 48px 16px; font-weight: 600; }
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
.order-list { display: flex; flex-direction: column; gap: 12px; }
.order-card {
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.order-card:active {
opacity: 0.92;
}
.order-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, #003D6B, transparent);
}
.order-card.rejected {
background: #FFFFFF;
border-color: rgba(220, 38, 38, 0.25);
}
.order-card.rejected::before {
background: linear-gradient(90deg, transparent, #DC2626, transparent);
}
.order-card.rejected .order-amount {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: #6B7280;
}
.order-card.rejected .info-label {
color: #6B7280;
}
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.method-badge.bank { background: rgba(0, 61, 107, 0.08); color: #005B9F; }
.method-badge.usdt { background: rgba(5, 150, 105, 0.08); color: #059669; }
.status-badge { font-size: 12px; font-weight: 700; }
.status-pending { color: #F8971F; }
.status-approved { color: #059669; }
.status-rejected { color: #DC2626; }
.order-body { }
.order-amount {
font-size: 22px;
font-weight: 900;
margin-bottom: 4px;
color: #003D6B;
}
.approved-amount { font-size: 12px; color: #059669; margin-bottom: 6px; font-weight: 600; }
.order-info-row { margin-bottom: 8px; }
.info-label { font-size: 13px; color: #003D6B; font-weight: 600; }
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.time-row { display: flex; justify-content: space-between; align-items: center; }
.time-label { font-size: 11px; color: #6B7280; }
.time-value { font-size: 11px; color: #1A1A2E; font-variant-numeric: tabular-nums; }
.order-remark {
font-size: 12px;
color: #003D6B;
background: rgba(0, 61, 107, 0.04);
padding: 6px 10px;
border-radius: 6px;
margin-top: 6px;
border-left: 2px solid #003D6B;
line-height: 1.45;
word-break: break-word;
}
.reject-reason {
margin-top: 8px;
font-size: 12px;
color: #DC2626;
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid rgba(220, 38, 38, 0.18);
line-height: 1.45;
word-break: break-word;
}
.card-detail-hint {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #E5E7EB;
}
.card-detail-summary {
font-size: 11px;
color: #6B7280;
font-weight: 600;
}
.card-detail-link {
font-size: 11px;
color: #003D6B;
font-weight: 700;
white-space: nowrap;
}
.card-detail-link-only {
display: block;
width: 100%;
margin-top: 10px;
padding: 0;
border: none;
background: none;
text-align: right;
font-size: 11px;
color: #003D6B;
font-weight: 700;
cursor: pointer;
}
.sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: 20px 0 8px;
}
.end-hint {
text-align: center;
font-size: 12px;
color: #6B7280;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
}
.detail-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
.detail-modal {
position: relative;
width: 100%;
max-width: 480px;
max-height: min(88vh, 720px);
overflow-y: auto;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-bottom: none;
border-radius: 14px 14px 0 0;
padding: 16px 16px calc(14px + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
}
.detail-close {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: #6B7280;
font-size: 13px;
cursor: pointer;
line-height: 1;
}
.detail-title {
margin: 0 28px 12px 0;
font-size: 15px;
font-weight: 600;
color: #1A1A2E;
}
.detail-summary {
margin-bottom: 14px;
padding: 12px;
border-radius: 10px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
}
.detail-summary-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.detail-amount {
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
color: #003D6B;
letter-spacing: -0.02em;
}
.detail-method-name {
font-size: 12px;
color: #6B7280;
font-weight: 500;
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 6px;
}
.detail-label {
font-size: 12px;
color: #6B7280;
flex-shrink: 0;
}
.detail-value {
font-size: 12px;
color: #1A1A2E;
text-align: right;
word-break: break-word;
}
.detail-summary .order-remark {
margin-top: 8px;
font-size: 11px;
color: #6B7280;
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid #E5E7EB;
line-height: 1.45;
word-break: break-word;
}
.detail-summary .reject-reason {
margin-top: 8px;
font-size: 11px;
color: #DC2626;
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid rgba(220, 38, 38, 0.15);
line-height: 1.45;
word-break: break-word;
}
.detail-summary .approved-amount {
font-size: 11px;
font-weight: 500;
}
.detail-audit {
margin-top: 2px;
padding-top: 12px;
border-top: 1px solid #E5E7EB;
}
.detail-audit-title {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
color: #6B7280;
letter-spacing: 0.02em;
}
.audit-track {
display: flex;
flex-direction: column;
gap: 0;
}
.audit-step {
display: flex;
gap: 8px;
min-height: 0;
}
.audit-step-rail {
flex: 0 0 10px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 4px;
}
.audit-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #E5E7EB;
flex-shrink: 0;
}
.audit-line {
flex: 1;
width: 1px;
min-height: 10px;
margin: 3px 0;
background: #E5E7EB;
}
.audit-step-body {
flex: 1;
min-width: 0;
padding-bottom: 12px;
}
.audit-step:last-child .audit-step-body {
padding-bottom: 0;
}
.audit-step-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.audit-step-title {
font-size: 12px;
font-weight: 600;
color: #1A1A2E;
line-height: 1.35;
}
.audit-step-time {
font-size: 10px;
color: #6B7280;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.audit-step-actor {
margin: 2px 0 0;
font-size: 11px;
color: #6B7280;
line-height: 1.4;
}
.audit-step-credited {
margin: 3px 0 0;
font-size: 11px;
font-weight: 500;
color: #059669;
line-height: 1.4;
}
.audit-step-note {
margin: 4px 0 0;
font-size: 11px;
color: #6B7280;
line-height: 1.45;
word-break: break-word;
}
.audit-step-box {
margin-top: 4px;
padding: 6px 8px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.4;
word-break: break-word;
}
.audit-step-box--reject {
background: transparent;
border: 1px solid rgba(220, 38, 38, 0.22);
}
.audit-step-box-label {
font-size: 10px;
font-weight: 500;
color: #DC2626;
letter-spacing: 0.01em;
}
.audit-step-box-text {
font-size: 11px;
color: #DC2626;
}
.audit-step--submitted .audit-dot {
background: #003D6B;
}
.audit-step--approved .audit-dot {
background: #059669;
}
.audit-step--rejected .audit-dot {
background: #DC2626;
}
.audit-step--revoked .audit-dot {
background: #6B7280;
}
.audit-step--reopened .audit-dot {
background: #003D6B;
}
.modal-reapply-btn {
width: 100%;
margin-top: 14px;
padding: 11px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
</style>