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

344 lines
13 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 } from 'vue';
import { useRoute, 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 route = useRoute();
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 detail = ref<DepositOrder | null>(null);
const loading = ref(true);
const notFound = ref(false);
async function loadDetail() {
loading.value = true;
notFound.value = false;
try {
const { data } = await api.get(`/player/deposit-orders/${route.params.id}`);
if (!data.data) {
notFound.value = true;
return;
}
detail.value = data.data;
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadDetail);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadDetail,
});
function goBack() {
router.push('/wallet/recharge/history');
}
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 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() ?? '';
}
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 auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
}
</script>
<template>
<div class="recharge-detail-page">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.order_detail') }}</h2>
<span class="header-spacer" />
</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="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="notFound || !detail" class="empty">{{ t('common.not_found') }}</div>
<template v-else>
<div class="summary-card">
<div class="summary-head">
<span class="method-badge" :class="detail.methodType === 'BANK' ? 'bank' : 'usdt'">
{{ detail.methodType }}
</span>
<span :class="['status-badge', statusClass(detail.status)]">{{ statusLabel(detail.status) }}</span>
</div>
<div class="order-no">{{ detail.orderNo }}</div>
<div class="order-amount">{{ formatMoney(detail.amount, locale) }}</div>
<div
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
class="approved-amount"
>
{{ t('recharge.credited') }}: {{ formatMoney(detail.approvedAmount, locale) }}
</div>
<div class="method-name">{{ detail.paymentMethodName || '-' }}</div>
<div class="time-row">
<span class="time-label">{{ t('recharge.apply_time') }}</span>
<span class="time-value">{{ formatOrderTime(detail.createdAt) }}</span>
</div>
<div v-if="detail.reviewedAt" class="time-row">
<span class="time-label">{{ t('recharge.review_time') }}</span>
<span class="time-value">{{ formatOrderTime(detail.reviewedAt) }}</span>
</div>
<div
v-if="orderNoteLine(detail)"
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
>
{{ orderNoteLine(detail) }}
</div>
</div>
<div v-if="detail.auditLogs?.length" class="audit-card">
<h3 class="audit-title">{{ t('recharge.audit_title') }}</h3>
<div class="audit-track">
<div
v-for="(entry, logIdx) in auditLogsForDisplay(detail)"
: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(detail).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="detail.status === 'REJECTED'"
type="button"
class="reapply-btn btn-gold-outline"
@click="reapply(detail)"
>
{{ t('recharge.reapply') }}
</button>
</template>
</div>
</template>
<style scoped>
.recharge-detail-page { padding: 0 0 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; flex: 1; text-align: center; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
.header-spacer { width: 40px; }
.state, .empty { display: flex; justify-content: center; padding: 48px 16px; color: #666; font-weight: 600; }
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
.summary-card,
.audit-card {
background: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
border: 1px solid rgba(212, 175, 55, 0.2);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.summary-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.method-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
.method-badge.bank { background: rgba(212,175,55,0.15); color: var(--primary-light); }
.method-badge.usdt { background: rgba(38,161,123,0.15); color: #26a17b; }
.status-badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
.status-approved { background: rgba(52,199,89,0.15); color: #4cd964; }
.status-rejected { background: rgba(255,69,58,0.15); color: #ff453a; }
.status-pending { background: rgba(212,175,55,0.15); color: var(--primary-light); }
.order-no { font-size: 11px; color: #888; font-family: monospace; margin-bottom: 4px; }
.order-amount { font-size: 24px; font-weight: 800; color: var(--text); margin-bottom: 4px; }
.approved-amount { font-size: 12px; color: #7eb87a; margin-bottom: 8px; }
.method-name { font-size: 12px; color: #888; margin-bottom: 8px; }
.time-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.time-label { color: #666; }
.time-value { color: #aaa; }
.reject-reason { margin-top: 8px; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(245,108,108,0.22); font-size: 12px; color: #f56c6c; line-height: 1.45; word-break: break-word; }
.order-remark { margin-top: 8px; font-size: 12px; color: #888; line-height: 1.45; }
.audit-title { margin: 0 0 12px; font-size: 14px; font-weight: 700; color: #ccc; }
.audit-track { display: flex; flex-direction: column; }
.audit-step { display: flex; gap: 8px; }
.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: #555; flex-shrink: 0; }
.audit-line { flex: 1; width: 1px; min-height: 10px; margin: 3px 0; background: #2a2a2a; }
.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: #ccc; }
.audit-step-time { font-size: 10px; color: #666; white-space: nowrap; }
.audit-step-actor { margin: 2px 0 0; font-size: 11px; color: #999; }
.audit-step-credited { margin: 3px 0 0; font-size: 11px; font-weight: 500; color: #7eb87a; }
.audit-step-note { margin: 4px 0 0; font-size: 11px; color: #888; 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; word-break: break-word; }
.audit-step-box--reject { border: 1px solid rgba(245,108,108,0.22); }
.audit-step-box-label { font-size: 10px; font-weight: 500; color: #c07070; }
.audit-step-box-text { font-size: 11px; color: #b08888; }
.audit-step--submitted .audit-dot { background: #c9a227; }
.audit-step--approved .audit-dot { background: #4cd964; }
.audit-step--rejected .audit-dot { background: #ff453a; }
.audit-step--revoked .audit-dot { background: #888; }
.audit-step--reopened .audit-dot { background: #c9a227; }
.reapply-btn {
display: block;
width: 100%;
margin-top: 4px;
padding: 12px;
font-size: 14px;
font-weight: 700;
border-radius: 10px;
cursor: pointer;
}
</style>