feat(player): theme-3 PC 列表弹窗详情与收件箱样式修复

投注/资金/充值/返水列表改为 theme-3 实心弹窗,修复收件箱背景、左侧裁切与滚动。
This commit is contained in:
mars
2026-07-10 17:01:53 +08:00
parent 4ccb0cde6d
commit 9abfbfcedd
20 changed files with 1628 additions and 62 deletions

View 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: var(--text-muted);
font-size: 13px;
}
.retry-btn {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.detail-card {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #1a323c;
}
.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: var(--text-muted);
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(244, 162, 97, 0.12); color: var(--primary-light); }
.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: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.amount-val {
font-size: 17px;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.amount-val.gold { color: var(--primary-light); }
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.card-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
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: var(--text);
margin-bottom: 4px;
}
.sel-meta {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-muted);
}
.sel-name { color: var(--text); font-weight: 700; }
.sel-odds { color: var(--primary-light); font-weight: 800; margin-left: auto; }
</style>

View File

@@ -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';
const props = 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: var(--text-muted);
font-size: 13px;
}
.hero {
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
}
.amount-hero {
font-size: 30px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--primary-light);
line-height: 1.1;
}
.batch-no {
margin-top: 6px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
}
.detail-rows {
padding: 4px 0 12px;
background: #1a323c;
}
.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: var(--text-muted);
font-weight: 600;
}
.row-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.row-val.muted {
color: var(--text-muted);
font-weight: 600;
}
</style>

View 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: #0f2027;
background: rgba(15, 32, 39, 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: #1a323c;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.65);
}
.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: #162d36;
flex-shrink: 0;
}
.detail-modal-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
}
.detail-modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.detail-modal-close:hover {
color: var(--text);
}
.detail-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
background: #1a323c;
}
.detail-modal-fade-enter-active,
.detail-modal-fade-leave-active {
transition: opacity 0.18s ease;
}
.detail-modal-fade-enter-active .detail-modal,
.detail-modal-fade-leave-active .detail-modal {
transition: transform 0.18s 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);
}
</style>

View File

@@ -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: var(--text-muted);
font-size: 13px;
}
.detail-card {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #1a323c;
}
.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: var(--text-muted);
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(244, 162, 97, 0.12); color: var(--primary-light); }
.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: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.amount-val {
font-size: 17px;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.amount-val.gold { color: var(--primary-light); }
.amount-val.method { font-size: 14px; font-weight: 700; }
.amount-val.date { font-size: 13px; color: var(--text-muted); 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: var(--text-muted);
line-height: 1.45;
}
.reapply-btn {
align-self: flex-start;
padding: 9px 24px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.card-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
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: var(--text-muted);
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: var(--text); }
.audit-step-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
.audit-step-actor { margin: 3px 0 0; font-size: 12px; color: var(--text-muted); }
.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: var(--text-muted); 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: var(--primary); }
.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: var(--primary); }
</style>

View 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.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.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: var(--text-muted);
font-size: 13px;
}
.retry-btn {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.hero {
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
}
.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: var(--primary-light); }
.amount-hero.neg { color: var(--danger); }
.type-label {
margin-top: 6px;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
}
.detail-rows {
padding: 4px 0;
background: #1a323c;
}
.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: var(--text-muted);
font-weight: 600;
}
.row-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
word-break: break-all;
}
.row-val.muted {
color: var(--text-muted);
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: #1a323c;
}
.link-btn {
width: 100%;
padding: 9px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
text-align: left;
}
.link-btn:hover {
background: rgba(244, 162, 97, 0.14);
}
</style>