Compare commits
2 Commits
ecfea51545
...
theme-4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a76c4a1bc | ||
|
|
b420664795 |
@@ -320,21 +320,22 @@ onMounted(() => {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #0A3558;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
transition: transform 0.2s, background 0.2s, border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fab-btn:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--primary-light);
|
||||
background: #0F4068;
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.fab-active {
|
||||
@@ -356,7 +357,7 @@ onMounted(() => {
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--bg-card);
|
||||
border: 2px solid #0A3558;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -383,8 +384,9 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: #001A33;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
@@ -440,10 +442,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
height: calc(100% - 48px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--bg-body);
|
||||
background: #0A2540;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from './GoldSpinner.vue';
|
||||
@@ -9,7 +8,10 @@ import { useAuthStore } from '../stores/auth';
|
||||
import { stripHtml } from '../utils/html';
|
||||
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
||||
|
||||
const router = useRouter();
|
||||
const emit = defineEmits<{
|
||||
'click-message': [id: string];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const {
|
||||
@@ -50,7 +52,7 @@ function messagePreview(item: PlayerMessage) {
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/messages/${id}`);
|
||||
emit('click-message', id);
|
||||
}
|
||||
|
||||
async function fetchPage(nextPage: number, append = false) {
|
||||
@@ -205,6 +207,7 @@ onActivated(tryLoad);
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
|
||||
298
apps/player/src/components/desktop/DesktopBetDetailModal.vue
Normal file
298
apps/player/src/components/desktop/DesktopBetDetailModal.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
betNo: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
type BetDetail = {
|
||||
betNo: string;
|
||||
stake: string;
|
||||
potentialReturn?: string;
|
||||
status: string;
|
||||
placedAt: string;
|
||||
legs: Array<{
|
||||
matchTitle?: string;
|
||||
marketLabel?: string;
|
||||
selectionName?: string;
|
||||
odds?: number;
|
||||
resultStatus?: string | null;
|
||||
score?: { ht: string | null; ft: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const detail = ref<BetDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
|
||||
const SEL_TRANS: Record<string, Record<string, string>> = {
|
||||
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
|
||||
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
|
||||
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
|
||||
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
|
||||
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
|
||||
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
|
||||
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
|
||||
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
|
||||
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
|
||||
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
|
||||
};
|
||||
|
||||
function translateSel(name: string): string {
|
||||
if (locale.value === 'zh-CN') return name;
|
||||
const exact = SEL_TRANS[name];
|
||||
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
|
||||
const sp = name.indexOf(' ');
|
||||
if (sp > 0) {
|
||||
const head = name.slice(0, sp);
|
||||
const m = SEL_TRANS[head];
|
||||
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + name.slice(sp);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async function loadDetail(betNo: string) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
detail.value = null;
|
||||
try {
|
||||
const { data } = await api.get(`/player/bets/${betNo}`);
|
||||
detail.value = data.data ?? null;
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.betNo] as const,
|
||||
([vis, no]) => {
|
||||
if (vis && no) void loadDetail(no);
|
||||
if (!vis) {
|
||||
detail.value = null;
|
||||
error.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function statusClass(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
WON: 'status-won',
|
||||
LOST: 'status-lost',
|
||||
PENDING: 'status-pending',
|
||||
PUSH: 'status-push',
|
||||
};
|
||||
return map[status?.toUpperCase()] ?? 'status-default';
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
WON: t('history.filter_won'),
|
||||
LOST: t('history.filter_lost'),
|
||||
PENDING: t('history.filter_pending'),
|
||||
PUSH: t('history.filter_push'),
|
||||
};
|
||||
return map[status?.toUpperCase()] ?? status;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopDetailModalShell
|
||||
:visible="visible"
|
||||
:title="t('bet.detail_title')"
|
||||
:max-width="680"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="32" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state">
|
||||
<p>{{ t('common.load_failed') }}</p>
|
||||
<button v-if="betNo" type="button" class="retry-btn" @click="loadDetail(betNo)">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="detail-card header-card">
|
||||
<div class="bet-no">{{ detail.betNo }}</div>
|
||||
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||
<div class="amount-row">
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.stake') }}</span>
|
||||
<span class="amount-val">{{ formatMoney(detail.stake, locale) }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.potential_payout') }}</span>
|
||||
<span class="amount-val gold">{{ detail.potentialReturn ? formatMoney(detail.potentialReturn, locale) : '-' }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.placed_at') }}</span>
|
||||
<span class="amount-val date">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="card-title">{{ t('bet.selections') }}</div>
|
||||
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-row">
|
||||
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
|
||||
<div class="sel-meta">
|
||||
<span class="sel-market">{{ leg.marketLabel || '' }}</span>
|
||||
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
|
||||
<span class="sel-odds">@{{ leg.odds }}</span>
|
||||
<span v-if="leg.score?.ft" class="sel-score">({{ t('history.ft') }}: {{ leg.score.ft }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('common.not_found') }}</div>
|
||||
</DesktopDetailModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 20px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.detail-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bet-no {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
|
||||
.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
|
||||
.status-pending { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.78); }
|
||||
.status-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
|
||||
.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.amount-val {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-val.gold { color: rgba(255, 255, 255, 0.78); }
|
||||
.amount-val.date { font-size: 13px; color: rgba(255, 255, 255, 0.56); font-weight: 600; }
|
||||
|
||||
.card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sel-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sel-row:last-child { border-bottom: none; }
|
||||
|
||||
.sel-match {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sel-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.sel-name { color: rgba(255, 255, 255, 0.94); font-weight: 700; }
|
||||
.sel-odds { color: rgba(255, 255, 255, 0.78); font-weight: 800; margin-left: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
|
||||
import { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
|
||||
import type { CashbackRecord } from '../../utils/cashback';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
record: CashbackRecord | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
function formatPeriod(start: string, end: string) {
|
||||
const opts: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit' };
|
||||
const s = new Date(start).toLocaleDateString(locale.value, opts);
|
||||
const e = new Date(end).toLocaleDateString(locale.value, opts);
|
||||
return `${s} – ${e}`;
|
||||
}
|
||||
|
||||
function formatRate(rate: string) {
|
||||
const n = parseFloat(rate);
|
||||
if (!Number.isFinite(n)) return rate;
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString(locale.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopDetailModalShell
|
||||
:visible="visible"
|
||||
:title="t('cashback.title')"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<template v-if="record">
|
||||
<div class="hero">
|
||||
<div class="amount-hero">{{ formatMoney(record.amount, locale) }}</div>
|
||||
<div class="batch-no">{{ record.batchNo }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-rows">
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('cashback.period') }}</span>
|
||||
<span class="row-val">{{ formatPeriod(record.periodStart, record.periodEnd) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('cashback.effective_stake') }}</span>
|
||||
<span class="row-val">{{ formatMoneyCompact(record.effectiveStake, locale) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('cashback.rate') }}</span>
|
||||
<span class="row-val">{{ formatRate(record.rate) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.time') }}</span>
|
||||
<span class="row-val muted">{{ formatTime(record.confirmedAt ?? record.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('common.not_found') }}</div>
|
||||
</DesktopDetailModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #001A33;
|
||||
}
|
||||
|
||||
.amount-hero {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.batch-no {
|
||||
margin-top: 6px;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.detail-rows {
|
||||
padding: 4px 0 12px;
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 11px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
width: 108px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-val {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.row-val.muted {
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
141
apps/player/src/components/desktop/DesktopDetailModalShell.vue
Normal file
141
apps/player/src/components/desktop/DesktopDetailModalShell.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
title: string;
|
||||
maxWidth?: number;
|
||||
}>(),
|
||||
{ maxWidth: 560 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.visible) close();
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown));
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="detail-modal-fade">
|
||||
<div v-if="visible" class="detail-modal-overlay" @click.self="close">
|
||||
<div
|
||||
class="detail-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
:style="{ maxWidth: `${maxWidth}px` }"
|
||||
>
|
||||
<header class="detail-modal-header">
|
||||
<h2 class="detail-modal-title">{{ title }}</h2>
|
||||
<button type="button" class="detail-modal-close" :aria-label="t('support.close')" @click="close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="detail-modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 26, 51, 0.96);
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: 100%;
|
||||
max-height: 84vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #0A2540;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.detail-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #001A33;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-modal-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.detail-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.detail-modal-close:hover {
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.detail-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.detail-modal-fade-enter-active,
|
||||
.detail-modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.detail-modal-fade-enter-active .detail-modal,
|
||||
.detail-modal-fade-leave-active .detail-modal {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.detail-modal-fade-enter-from,
|
||||
.detail-modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.detail-modal-fade-enter-from .detail-modal,
|
||||
.detail-modal-fade-leave-to .detail-modal {
|
||||
transform: scale(0.97) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import type { DepositAuditLog, PlayerDepositOrder } from '../../utils/depositOrderLookup';
|
||||
import {
|
||||
auditActionTone,
|
||||
auditActorSecondary,
|
||||
formatDepositAuditRemark,
|
||||
shouldShowAuditRejectInTimeline,
|
||||
} from '../../utils/depositAuditDisplay';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
order: PlayerDepositOrder | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const detail = computed(() => props.order);
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
APPROVED: 'status-won',
|
||||
REJECTED: 'status-lost',
|
||||
PENDING: 'status-pending',
|
||||
};
|
||||
return map[status?.toUpperCase()] ?? 'status-default';
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const s = status?.toUpperCase();
|
||||
if (s === 'APPROVED') return t('recharge.status_approved');
|
||||
if (s === 'REJECTED') return t('recharge.status_rejected');
|
||||
return t('recharge.status_pending');
|
||||
}
|
||||
|
||||
function methodLabel(order: PlayerDepositOrder) {
|
||||
if (order.methodType === 'USDT') return 'USDT';
|
||||
return order.paymentMethodName || order.methodType || '-';
|
||||
}
|
||||
|
||||
function reapply(order: PlayerDepositOrder) {
|
||||
close();
|
||||
const query: Record<string, string> = {
|
||||
orderId: order.id,
|
||||
methodType: order.methodType,
|
||||
amount: order.amount,
|
||||
};
|
||||
if (order.paymentMethodId) query.methodId = order.paymentMethodId;
|
||||
void router.push({ path: '/wallet/recharge', query });
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function orderNote(order: PlayerDepositOrder): { 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: PlayerDepositOrder) {
|
||||
const note = orderNote(order);
|
||||
return note ? `${note.label}: ${note.text}` : null;
|
||||
}
|
||||
|
||||
function auditActionLabel(action: string) {
|
||||
const key = `recharge.audit_${action.toLowerCase()}`;
|
||||
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(locale.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function auditRemarkForTimeline(log: DepositAuditLog, order: PlayerDepositOrder) {
|
||||
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
|
||||
return null;
|
||||
}
|
||||
return formatDepositAuditRemark(log, t);
|
||||
}
|
||||
|
||||
function auditLogsForDisplay(order: PlayerDepositOrder) {
|
||||
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>
|
||||
<DesktopDetailModalShell
|
||||
:visible="visible"
|
||||
:title="t('recharge.order_detail')"
|
||||
:max-width="680"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<template v-if="detail">
|
||||
<div class="detail-card header-card">
|
||||
<div class="order-no">{{ detail.orderNo }}</div>
|
||||
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||
<div class="amount-row">
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('wallet.amount') }}</span>
|
||||
<span class="amount-val">{{ formatMoney(detail.amount, locale) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
|
||||
class="amount-item"
|
||||
>
|
||||
<span class="amount-label">{{ t('recharge.credited') }}</span>
|
||||
<span class="amount-val gold">{{ formatMoney(detail.approvedAmount, locale) }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.method') }}</span>
|
||||
<span class="amount-val method">{{ methodLabel(detail) }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.apply_time') }}</span>
|
||||
<span class="amount-val date">{{ new Date(detail.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detail.reviewedAt" class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.review_time') }}</span>
|
||||
<span class="amount-val date">{{ new Date(detail.reviewedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderNoteLine(detail)"
|
||||
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
|
||||
>
|
||||
{{ orderNoteLine(detail) }}
|
||||
</div>
|
||||
<button
|
||||
v-if="detail.status === 'REJECTED'"
|
||||
type="button"
|
||||
class="reapply-btn"
|
||||
@click="reapply(detail)"
|
||||
>
|
||||
{{ t('recharge.reapply') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.auditLogs?.length" class="detail-card">
|
||||
<div class="card-title">{{ t('recharge.audit_title') }}</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('common.not_found') }}</div>
|
||||
</DesktopDetailModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.detail-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
|
||||
.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
|
||||
.status-pending { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.78); }
|
||||
.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.amount-val {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-val.gold { color: rgba(255, 255, 255, 0.78); }
|
||||
.amount-val.method { font-size: 14px; font-weight: 700; }
|
||||
.amount-val.date { font-size: 13px; color: rgba(255, 255, 255, 0.56); font-weight: 600; }
|
||||
|
||||
.reject-reason {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 69, 58, 0.22);
|
||||
font-size: 13px;
|
||||
color: #ff8a82;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.order-remark {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.reapply-btn {
|
||||
align-self: flex-start;
|
||||
padding: 9px 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.audit-track { display: flex; flex-direction: column; }
|
||||
.audit-step { display: flex; gap: 10px; }
|
||||
.audit-step-rail {
|
||||
flex: 0 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.audit-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.audit-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 12px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.audit-step-body { flex: 1; min-width: 0; padding-bottom: 14px; }
|
||||
.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: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.94); }
|
||||
.audit-step-time { font-size: 11px; color: rgba(255, 255, 255, 0.56); white-space: nowrap; }
|
||||
.audit-step-actor { margin: 3px 0 0; font-size: 12px; color: rgba(255, 255, 255, 0.56); }
|
||||
.audit-step-credited { margin: 4px 0 0; font-size: 12px; font-weight: 600; color: #7eb87a; }
|
||||
.audit-step-note { margin: 4px 0 0; font-size: 12px; color: rgba(255, 255, 255, 0.56); line-height: 1.45; }
|
||||
.audit-step-box {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.audit-step-box--reject { border: 1px solid rgba(255, 69, 58, 0.22); }
|
||||
.audit-step-box-label { font-size: 10px; font-weight: 600; color: #c07070; }
|
||||
.audit-step-box-text { font-size: 12px; color: #b08888; }
|
||||
.audit-step--submitted .audit-dot { background: rgba(255, 255, 255, 0.92); }
|
||||
.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: rgba(255, 255, 255, 0.68); }
|
||||
</style>
|
||||
292
apps/player/src/components/desktop/DesktopTxDetailModal.vue
Normal file
292
apps/player/src/components/desktop/DesktopTxDetailModal.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { txTypeKey, txDisplayType, txSummaryLabel, isCashbackType } from '../../utils/walletTx';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
transactionId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
type TxDetail = {
|
||||
transactionId: string;
|
||||
transactionType: string;
|
||||
displayType?: string;
|
||||
summary?: string | null;
|
||||
amount: string;
|
||||
balanceBefore?: string;
|
||||
balanceAfter?: string;
|
||||
frozenBefore?: string;
|
||||
frozenAfter?: string;
|
||||
createdAt: string;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
betNo?: string | null;
|
||||
cashbackBatchNo?: string | null;
|
||||
};
|
||||
|
||||
const detail = ref<TxDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
detail.value = null;
|
||||
try {
|
||||
const { data } = await api.get(`/player/wallet/transactions/${id}`);
|
||||
detail.value = data.data ?? null;
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.transactionId] as const,
|
||||
([vis, id]) => {
|
||||
if (vis && id) void loadDetail(id);
|
||||
if (!vis) {
|
||||
detail.value = null;
|
||||
error.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
function txLabel(tx: TxDetail): string {
|
||||
const key = txTypeKey(txDisplayType(tx));
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return tx.transactionType;
|
||||
}
|
||||
|
||||
const showFrozen = (tx: TxDetail) =>
|
||||
parseFloat(tx.frozenBefore || '0') > 0 || parseFloat(tx.frozenAfter || '0') > 0;
|
||||
|
||||
const betNo = computed(() => detail.value?.betNo?.trim() || null);
|
||||
|
||||
const cashbackBatchNo = computed(() => {
|
||||
const tx = detail.value;
|
||||
if (!tx || !isCashbackType(tx.transactionType)) return null;
|
||||
return tx.cashbackBatchNo ?? tx.referenceId ?? null;
|
||||
});
|
||||
|
||||
function goBetDetail() {
|
||||
if (!betNo.value) return;
|
||||
close();
|
||||
void router.push({ path: '/bets', query: { betNo: betNo.value } });
|
||||
}
|
||||
|
||||
function goCashbackDetail() {
|
||||
if (!cashbackBatchNo.value) return;
|
||||
close();
|
||||
void router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopDetailModalShell
|
||||
:visible="visible"
|
||||
:title="t('wallet.transaction_detail')"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="32" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="state">
|
||||
<p>{{ t('common.load_failed') }}</p>
|
||||
<button v-if="transactionId" type="button" class="retry-btn" @click="loadDetail(transactionId)">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="hero">
|
||||
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatMoney(detail.amount, locale) }}
|
||||
</div>
|
||||
<div class="type-label">{{ txLabel(detail) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-rows">
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.note') }}</span>
|
||||
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.balance_before') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.balance_after') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.balanceAfter, locale) }}</span>
|
||||
</div>
|
||||
<div v-if="showFrozen(detail)" class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.detail_frozen_before') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.frozenBefore, locale) }}</span>
|
||||
</div>
|
||||
<div v-if="showFrozen(detail)" class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.detail_frozen_after') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.time') }}</span>
|
||||
<span class="row-val muted">{{ new Date(detail.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.transaction_id') }}</span>
|
||||
<span class="row-val mono muted">{{ detail.transactionId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="betNo || cashbackBatchNo" class="link-actions">
|
||||
<button v-if="betNo" type="button" class="link-btn" @click="goBetDetail">
|
||||
{{ t('wallet.detail_bet_link') }} · {{ betNo }}
|
||||
</button>
|
||||
<button v-if="cashbackBatchNo" type="button" class="link-btn" @click="goCashbackDetail">
|
||||
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('common.not_found') }}</div>
|
||||
</DesktopDetailModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 20px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #001A33;
|
||||
}
|
||||
|
||||
.amount-hero {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.amount-hero.pos { color: rgba(255, 255, 255, 0.78); }
|
||||
.amount-hero.neg { color: rgba(255, 255, 255, 0.62); }
|
||||
|
||||
.type-label {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.detail-rows {
|
||||
padding: 4px 0;
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 11px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
width: 108px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-val {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.row-val.muted {
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 18px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #0A2540;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -129,20 +129,21 @@ function formatOdds(odds: string) {
|
||||
gap: 3px;
|
||||
min-height: 42px;
|
||||
padding: 6px 3px;
|
||||
background: #161616;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elevated, rgba(255,255,255,0.08));
|
||||
border: 1px solid var(--border, rgba(255,255,255,0.1));
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.score-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: #2d281a;
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
|
||||
border-color: var(--border-gold, rgba(255,255,255,0.2));
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15), 0 0 10px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.score-card.selected .score-line {
|
||||
color: #e8d48a;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.score-card.selected .odds {
|
||||
@@ -150,13 +151,14 @@ function formatOdds(odds: string) {
|
||||
}
|
||||
|
||||
.score-card--locked {
|
||||
background: #111;
|
||||
border-color: #333;
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.score-card--locked .score-line,
|
||||
.score-card--locked .odds {
|
||||
color: #666;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
}
|
||||
|
||||
.score-line {
|
||||
@@ -168,7 +170,7 @@ function formatOdds(odds: string) {
|
||||
.odds {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
color: var(--primary-light, rgba(255,255,255,0.68));
|
||||
}
|
||||
|
||||
.cs-panel--dense {
|
||||
@@ -192,6 +194,7 @@ function formatOdds(odds: string) {
|
||||
min-height: 34px;
|
||||
padding: 4px 2px;
|
||||
gap: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cs-panel--dense .score-line {
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
gap: 20px 32px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 18px;
|
||||
background: var(--desktop-surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.desktop-records-header-main {
|
||||
@@ -155,9 +158,10 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: rgba(10, 37, 64, 0.94);
|
||||
background: var(--desktop-surface-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -182,7 +186,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
background: #0A2540;
|
||||
background: var(--desktop-surface-header);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -240,8 +244,8 @@
|
||||
.desktop-records-pagination {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0, 26, 51, 0.5);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--desktop-surface-header);
|
||||
}
|
||||
|
||||
.desktop-records-loading,
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/* Teleport 弹窗挂在 body 下,须用 :root 暴露实心色 */
|
||||
:root {
|
||||
--desktop-surface-solid: #0A2540;
|
||||
--desktop-surface-elevated: #0A3558;
|
||||
--desktop-surface-header: #001A33;
|
||||
--desktop-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.desktop-shell {
|
||||
/* Color Palette overrides */
|
||||
--primary: rgba(255, 255, 255, 0.92);
|
||||
@@ -36,6 +44,9 @@
|
||||
--desktop-border: rgba(255, 255, 255, 0.1);
|
||||
--desktop-sidebar-bg: rgba(10, 37, 64, 0.95);
|
||||
--desktop-sidebar-panel-bg: linear-gradient(rgba(0, 26, 51, 0.88), rgba(0, 26, 51, 0.88));
|
||||
--desktop-surface-solid: #0A2540;
|
||||
--desktop-surface-elevated: #0A3558;
|
||||
--desktop-surface-header: #001A33;
|
||||
|
||||
/* Reset typography inside desktop */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
63
apps/player/src/utils/depositOrderLookup.ts
Normal file
63
apps/player/src/utils/depositOrderLookup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import api from '../api';
|
||||
|
||||
export type DepositAuditLog = {
|
||||
id: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
statusBefore: string | null;
|
||||
statusAfter: string;
|
||||
amount: string | null;
|
||||
approvedAmount: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PlayerDepositOrder = {
|
||||
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;
|
||||
bankName?: string | null;
|
||||
auditLogs?: DepositAuditLog[];
|
||||
};
|
||||
|
||||
export async function fetchDepositOrderAuditLogs(orderId: string): Promise<DepositAuditLog[]> {
|
||||
try {
|
||||
const { data } = await api.get(`/player/deposit-orders/${orderId}/audit-logs`);
|
||||
const items = data.data?.items ?? data.data ?? [];
|
||||
return Array.isArray(items) ? (items as DepositAuditLog[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichDepositOrder(order: PlayerDepositOrder): Promise<PlayerDepositOrder> {
|
||||
if (order.auditLogs?.length) return order;
|
||||
const auditLogs = await fetchDepositOrderAuditLogs(order.id);
|
||||
return auditLogs.length ? { ...order, auditLogs } : order;
|
||||
}
|
||||
|
||||
export async function findDepositOrderById(orderId: string): Promise<PlayerDepositOrder | null> {
|
||||
let page = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
while (true) {
|
||||
const { data } = await api.get('/player/deposit-orders', { params: { page, pageSize } });
|
||||
const result = data.data ?? { items: [], total: 0, pageSize };
|
||||
const items = (result.items ?? []) as PlayerDepositOrder[];
|
||||
const found = items.find((order) => order.id === orderId);
|
||||
if (found) return enrichDepositOrder(found);
|
||||
|
||||
const total = result.total ?? 0;
|
||||
if (!items.length || page * pageSize >= total) return null;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import MobileBetDetailView from './MobileBetDetailView.vue';
|
||||
import DesktopBetDetailView from './desktop/DesktopBetDetailView.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isDesktop } = useViewport();
|
||||
|
||||
onMounted(() => {
|
||||
if (!isDesktop.value) return;
|
||||
const betNo = route.params.betNo;
|
||||
if (betNo) {
|
||||
void router.replace({ path: '/bets', query: { betNo: String(betNo) } });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopBetDetailView v-if="isDesktop" />
|
||||
<MobileBetDetailView v-else />
|
||||
|
||||
@@ -159,7 +159,11 @@ onActivated(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
|
||||
<MessageListPanel
|
||||
v-if="inboxEnabled"
|
||||
v-show="activeTab === 'messages'"
|
||||
@click-message="(id) => router.push(`/messages/${id}`)"
|
||||
/>
|
||||
<CustomerServicePanel v-if="activeTab === 'support'" />
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import MobileRechargeDetailView from './MobileRechargeDetailView.vue';
|
||||
import DesktopRechargeDetailView from './desktop/DesktopRechargeDetailView.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isDesktop } = useViewport();
|
||||
|
||||
onMounted(() => {
|
||||
if (!isDesktop.value) return;
|
||||
const id = route.params.id;
|
||||
if (id) {
|
||||
void router.replace({
|
||||
path: '/wallet/recharge/history',
|
||||
query: { orderId: String(id) },
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import MobileWalletTransactionDetailView from './MobileWalletTransactionDetailView.vue';
|
||||
import DesktopWalletTransactionDetailView from './desktop/DesktopWalletTransactionDetailView.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isDesktop } = useViewport();
|
||||
|
||||
onMounted(() => {
|
||||
if (!isDesktop.value) return;
|
||||
const transactionId = route.params.transactionId;
|
||||
if (transactionId) {
|
||||
void router.replace({
|
||||
path: '/wallet/detail',
|
||||
query: { transactionId: String(transactionId) },
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopWalletTransactionDetailView v-if="isDesktop" />
|
||||
<MobileWalletTransactionDetailView v-else />
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
||||
import DesktopCashbackDetailModal from '../../components/desktop/DesktopCashbackDetailModal.vue';
|
||||
import { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
|
||||
import { parseCashbackApiData, type CashbackRecord } from '../../utils/cashback';
|
||||
|
||||
const COL_COUNT = 6;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const detailModalVisible = ref(false);
|
||||
const detailRecord = ref<CashbackRecord | null>(null);
|
||||
const allItems = ref<CashbackRecord[]>([]);
|
||||
const loading = ref(true);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
|
||||
function openDetail(record: CashbackRecord) {
|
||||
detailRecord.value = record;
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetailFromQuery() {
|
||||
const batchNo = route.query.batchNo;
|
||||
if (typeof batchNo !== 'string' || !batchNo) return;
|
||||
const match = allItems.value.find((row) => row.batchNo === batchNo);
|
||||
if (match) {
|
||||
openDetail(match);
|
||||
const { batchNo: _removed, ...rest } = route.query;
|
||||
void router.replace({ path: route.path, query: rest });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.batchNo, openDetailFromQuery);
|
||||
watch(allItems, openDetailFromQuery);
|
||||
watch(detailModalVisible, (visible) => {
|
||||
if (!visible) detailRecord.value = null;
|
||||
});
|
||||
|
||||
const total = computed(() => allItems.value.length);
|
||||
|
||||
const items = computed(() => {
|
||||
@@ -75,8 +103,14 @@ watch(pageSize, () => {
|
||||
page.value = 1;
|
||||
});
|
||||
|
||||
onMounted(loadData);
|
||||
onActivated(loadData);
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
openDetailFromQuery();
|
||||
});
|
||||
onActivated(async () => {
|
||||
await loadData();
|
||||
openDetailFromQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +147,12 @@ onActivated(loadData);
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else-if="items.length">
|
||||
<tr v-for="row in items" :key="row.id" class="hover-row">
|
||||
<tr
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="hover-row clickable-row"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
<td>{{ formatPeriod(row.periodStart, row.periodEnd) }}</td>
|
||||
<td>{{ formatMoneyCompact(row.effectiveStake, locale) }}</td>
|
||||
<td>{{ formatRate(row.rate) }}</td>
|
||||
@@ -140,6 +179,8 @@ onActivated(loadData);
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopCashbackDetailModal v-model:visible="detailModalVisible" :record="detailRecord" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopBetDetailModal from '../../components/desktop/DesktopBetDetailModal.vue';
|
||||
import { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
const COL_COUNT = 8;
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const detailModalVisible = ref(false);
|
||||
const detailBetNo = ref<string | null>(null);
|
||||
|
||||
function openDetail(betNo: string) {
|
||||
detailBetNo.value = betNo;
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetailFromQuery() {
|
||||
const betNo = route.query.betNo;
|
||||
if (typeof betNo === 'string' && betNo) {
|
||||
openDetail(betNo);
|
||||
const { betNo: _removed, ...rest } = route.query;
|
||||
void router.replace({ path: route.path, query: rest });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.betNo, openDetailFromQuery);
|
||||
watch(detailModalVisible, (visible) => {
|
||||
if (!visible) detailBetNo.value = null;
|
||||
});
|
||||
|
||||
const items = ref<BetHistoryItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -62,7 +86,10 @@ useOnLocaleChange(() => {
|
||||
});
|
||||
|
||||
onActivated(() => { void loadPage(page.value); });
|
||||
onMounted(() => { void loadPage(1); });
|
||||
onMounted(() => {
|
||||
void loadPage(1);
|
||||
openDetailFromQuery();
|
||||
});
|
||||
|
||||
function statusClass(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
@@ -154,7 +181,7 @@ function statusLabel(status: string) {
|
||||
v-for="bet in items"
|
||||
:key="bet.betNo"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/bets/${bet.betNo}`)"
|
||||
@click="openDetail(bet.betNo)"
|
||||
>
|
||||
<td class="id-cell">{{ bet.betNo }}</td>
|
||||
<td>{{ bet.matchTitle || '-' }}</td>
|
||||
@@ -187,6 +214,8 @@ function statusLabel(status: string) {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopBetDetailModal v-model:visible="detailModalVisible" :bet-no="detailBetNo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
||||
import DesktopRechargeDetailModal from '../../components/desktop/DesktopRechargeDetailModal.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { enrichDepositOrder, findDepositOrderById, type PlayerDepositOrder } from '../../utils/depositOrderLookup';
|
||||
|
||||
const COL_COUNT = 5;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
type RechargeOrder = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
methodType?: string;
|
||||
bankName?: string | null;
|
||||
paymentMethodName?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
const detailModalVisible = ref(false);
|
||||
const detailOrder = ref<PlayerDepositOrder | null>(null);
|
||||
|
||||
async function openDetail(order: PlayerDepositOrder) {
|
||||
detailOrder.value = await enrichDepositOrder(order);
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
async function openDetailFromQuery() {
|
||||
const orderId = route.query.orderId;
|
||||
if (typeof orderId !== 'string' || !orderId) return;
|
||||
|
||||
const existing = items.value.find((order) => order.id === orderId);
|
||||
const order = existing ?? await findDepositOrderById(orderId);
|
||||
if (order) await openDetail(order);
|
||||
|
||||
const { orderId: _removed, ...rest } = route.query;
|
||||
void router.replace({ path: route.path, query: rest });
|
||||
}
|
||||
|
||||
watch(() => route.query.orderId, () => {
|
||||
void openDetailFromQuery();
|
||||
});
|
||||
watch(detailModalVisible, (visible) => {
|
||||
if (!visible) detailOrder.value = null;
|
||||
});
|
||||
|
||||
type RechargeOrder = PlayerDepositOrder;
|
||||
|
||||
const items = ref<RechargeOrder[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -46,8 +67,14 @@ async function loadPage(p: number) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadPage(1));
|
||||
onActivated(() => loadPage(page.value));
|
||||
onMounted(async () => {
|
||||
await loadPage(1);
|
||||
await openDetailFromQuery();
|
||||
});
|
||||
onActivated(async () => {
|
||||
await loadPage(page.value);
|
||||
await openDetailFromQuery();
|
||||
});
|
||||
|
||||
function methodLabel(order: RechargeOrder) {
|
||||
if (order.methodType === 'USDT') return 'USDT';
|
||||
@@ -109,7 +136,7 @@ function statusLabel(status: string) {
|
||||
v-for="order in items"
|
||||
:key="order.id"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/wallet/recharge/history/${order.id}`)"
|
||||
@click="openDetail(order)"
|
||||
>
|
||||
<td class="id-cell">{{ order.orderNo || order.id }}</td>
|
||||
<td>{{ methodLabel(order) }}</td>
|
||||
@@ -139,5 +166,7 @@ function statusLabel(status: string) {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopRechargeDetailModal v-model:visible="detailModalVisible" :order="detailOrder" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function goBack() {
|
||||
if (window.history.state && window.history.state.back) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/wallet');
|
||||
const detailModalVisible = ref(false);
|
||||
const detailTxId = ref<string | null>(null);
|
||||
|
||||
function openDetail(txId: string) {
|
||||
detailTxId.value = txId;
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetailFromQuery() {
|
||||
const transactionId = route.query.transactionId;
|
||||
if (typeof transactionId === 'string' && transactionId) {
|
||||
openDetail(transactionId);
|
||||
const { transactionId: _removed, ...rest } = route.query;
|
||||
void router.replace({ path: route.path, query: rest });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.transactionId, openDetailFromQuery);
|
||||
watch(detailModalVisible, (visible) => {
|
||||
if (!visible) detailTxId.value = null;
|
||||
});
|
||||
|
||||
type Transaction = {
|
||||
transactionType: string;
|
||||
displayType?: string;
|
||||
@@ -59,8 +75,14 @@ async function loadPage(p: number) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadPage(1));
|
||||
onActivated(() => loadPage(1));
|
||||
onMounted(() => {
|
||||
void loadPage(1);
|
||||
openDetailFromQuery();
|
||||
});
|
||||
onActivated(() => {
|
||||
void loadPage(1);
|
||||
openDetailFromQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,7 +116,7 @@ onActivated(() => loadPage(1));
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
|
||||
@click="openDetail(tx.transactionId)"
|
||||
>
|
||||
<td class="type-cell">{{ txLabel(tx) }}</td>
|
||||
<td class="note-cell">{{ txSummaryLabel(tx, t) || '-' }}</td>
|
||||
@@ -118,6 +140,11 @@ onActivated(() => loadPage(1));
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<DesktopTxDetailModal
|
||||
v-model:visible="detailModalVisible"
|
||||
:transaction-id="detailTxId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,10 +173,10 @@ onActivated(() => loadPage(1));
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--desktop-surface-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-wrap { flex: 1; overflow-x: auto; overflow-y: auto; }
|
||||
@@ -163,7 +190,7 @@ onActivated(() => loadPage(1));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: #0A2540;
|
||||
background: var(--desktop-surface-header);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--desktop-border);
|
||||
|
||||
@@ -109,11 +109,11 @@ function txLabel(tx: TxDetail): string {
|
||||
<span class="row-val mono">{{ formatMoney(detail.balanceAfter, locale) }}</span>
|
||||
</div>
|
||||
<div v-if="parseFloat(detail.frozenBefore || '0') > 0 || parseFloat(detail.frozenAfter || '0') > 0" class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.frozen_before') || '冻结前' }}</span>
|
||||
<span class="row-label">{{ t('wallet.detail_frozen_before') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.frozenBefore, locale) }}</span>
|
||||
</div>
|
||||
<div v-if="parseFloat(detail.frozenBefore || '0') > 0 || parseFloat(detail.frozenAfter || '0') > 0" class="detail-row">
|
||||
<span class="row-label">{{ t('wallet.frozen_after') || '冻结后' }}</span>
|
||||
<span class="row-label">{{ t('wallet.detail_frozen_after') }}</span>
|
||||
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
||||
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const detailModalVisible = ref(false);
|
||||
const detailTxId = ref<string | null>(null);
|
||||
|
||||
function openDetail(txId: string) {
|
||||
detailTxId.value = txId;
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
type Transaction = {
|
||||
transactionType: string;
|
||||
@@ -113,7 +121,7 @@ onActivated(() => fetchData(1));
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
|
||||
@click="openDetail(tx.transactionId)"
|
||||
>
|
||||
<td class="type-cell">{{ txLabel(tx) }}</td>
|
||||
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
|
||||
@@ -139,6 +147,8 @@ onActivated(() => fetchData(1));
|
||||
<span>{{ t('wallet.no_records') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopTxDetailModal v-model:visible="detailModalVisible" :transaction-id="detailTxId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user