feat(theme-4): fix dark backgrounds, quick bet, inbox spacing, customer service theme

This commit is contained in:
mars
2026-07-09 18:03:57 +08:00
parent dcf9afacd6
commit ecfea51545
124 changed files with 26710 additions and 8578 deletions

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { formatMoney } from '../utils/localeDisplay';
import {
auditActionTone,
auditActorSecondary,
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../utils/depositAuditDisplay';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
interface DepositAuditLog {
id: string;
action: string;
actorType: string;
statusBefore: string | null;
statusAfter: string;
amount: string | null;
approvedAmount: string | null;
remark: string | null;
createdAt: string;
}
interface DepositOrder {
id: string;
orderNo: string;
paymentMethodId?: string;
methodType: string;
amount: string;
status: string;
approvedAmount: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
auditLogs?: DepositAuditLog[];
}
const detail = ref<DepositOrder | null>(null);
const loading = ref(true);
const notFound = ref(false);
async function loadDetail() {
loading.value = true;
notFound.value = false;
try {
const { data } = await api.get(`/player/deposit-orders/${route.params.id}`);
if (!data.data) {
notFound.value = true;
return;
}
detail.value = data.data;
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadDetail);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: loadDetail,
});
function goBack() {
router.push('/wallet/recharge/history');
}
function statusClass(s: string) {
if (s === 'APPROVED') return 'status-approved';
if (s === 'REJECTED') return 'status-rejected';
return 'status-pending';
}
function statusLabel(s: string) {
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
function reapply(order: DepositOrder) {
const query: Record<string, string> = {
orderId: order.id,
methodType: order.methodType,
amount: order.amount,
};
if (order.paymentMethodId) query.methodId = order.paymentMethodId;
router.push({ path: '/wallet/recharge', query });
}
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function orderNote(order: DepositOrder): { label: string; text: string } | null {
const rejectReason = normalizeText(order.rejectReason);
const remark = normalizeText(order.remark);
if (order.status === 'REJECTED') {
const text = rejectReason || remark;
if (!text) return null;
return { label: t('recharge.reject_reason'), text };
}
if (remark) return { label: t('recharge.remark'), text: remark };
return null;
}
function orderNoteLine(order: DepositOrder) {
const note = orderNote(order);
return note ? `${note.label}: ${note.text}` : null;
}
function auditActionLabel(action: string) {
const key = `recharge.audit_${action.toLowerCase()}` as const;
const translated = t(key);
return translated !== key ? translated : action;
}
function auditStepClass(action: string) {
return `audit-step--${auditActionTone(action)}`;
}
function formatAuditTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatOrderTime(iso: string) {
return new Date(iso).toLocaleString();
}
function auditRemarkForTimeline(log: DepositAuditLog, order: DepositOrder) {
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
return null;
}
return formatDepositAuditRemark(log, t);
}
function auditLogsForDisplay(order: DepositOrder) {
return [...(order.auditLogs ?? [])]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((log) => ({
log,
actor: auditActorSecondary(log, t),
remark: auditRemarkForTimeline(log, order),
}));
}
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
}
</script>
<template>
<div class="recharge-detail-page">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.order_detail') }}</h2>
<span class="header-spacer" />
</div>
<div
class="pull-indicator"
:style="{ height: `${pullDistance}px`, opacity: Math.min(pullDistance / 48, 1) }"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="notFound || !detail" class="empty">{{ t('common.not_found') }}</div>
<template v-else>
<div class="summary-card">
<div class="summary-head">
<span class="method-badge" :class="detail.methodType === 'BANK' ? 'bank' : 'usdt'">
{{ detail.methodType }}
</span>
<span :class="['status-badge', statusClass(detail.status)]">{{ statusLabel(detail.status) }}</span>
</div>
<div class="order-no">{{ detail.orderNo }}</div>
<div class="order-amount">{{ formatMoney(detail.amount, locale) }}</div>
<div
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
class="approved-amount"
>
{{ t('recharge.credited') }}: {{ formatMoney(detail.approvedAmount, locale) }}
</div>
<div class="method-name">{{ detail.paymentMethodName || '-' }}</div>
<div class="time-row">
<span class="time-label">{{ t('recharge.apply_time') }}</span>
<span class="time-value">{{ formatOrderTime(detail.createdAt) }}</span>
</div>
<div v-if="detail.reviewedAt" class="time-row">
<span class="time-label">{{ t('recharge.review_time') }}</span>
<span class="time-value">{{ formatOrderTime(detail.reviewedAt) }}</span>
</div>
<div
v-if="orderNoteLine(detail)"
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
>
{{ orderNoteLine(detail) }}
</div>
</div>
<div v-if="detail.auditLogs?.length" class="audit-card">
<h3 class="audit-title">{{ t('recharge.audit_title') }}</h3>
<div class="audit-track">
<div
v-for="(entry, logIdx) in auditLogsForDisplay(detail)"
:key="entry.log.id"
class="audit-step"
:class="auditStepClass(entry.log.action)"
>
<div class="audit-step-rail" aria-hidden="true">
<span class="audit-dot" />
<span v-if="logIdx < auditLogsForDisplay(detail).length - 1" class="audit-line" />
</div>
<div class="audit-step-body">
<div class="audit-step-head">
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
</div>
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
<p
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
class="audit-step-credited"
>
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
</p>
<div v-if="entry.remark?.kind === 'reject'" class="audit-step-box audit-step-box--reject">
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
</div>
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
{{ auditNoteDisplayText(entry.remark) }}
</p>
</div>
</div>
</div>
</div>
<button
v-if="detail.status === 'REJECTED'"
type="button"
class="reapply-btn btn-gold-outline"
@click="reapply(detail)"
>
{{ t('recharge.reapply') }}
</button>
</template>
</div>
</template>
<style scoped>
.recharge-detail-page { padding: 0 0 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; flex: 1; text-align: center; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
.header-spacer { width: 40px; }
.state, .empty { display: flex; justify-content: center; padding: 48px 16px; color: #666; font-weight: 600; }
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
.summary-card,
.audit-card {
background: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
border: 1px solid rgba(212, 175, 55, 0.2);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.summary-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.method-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
.method-badge.bank { background: rgba(212,175,55,0.15); color: var(--primary-light); }
.method-badge.usdt { background: rgba(38,161,123,0.15); color: #26a17b; }
.status-badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
.status-approved { background: rgba(52,199,89,0.15); color: #4cd964; }
.status-rejected { background: rgba(255,69,58,0.15); color: #ff453a; }
.status-pending { background: rgba(212,175,55,0.15); color: var(--primary-light); }
.order-no { font-size: 11px; color: #888; font-family: monospace; margin-bottom: 4px; }
.order-amount { font-size: 24px; font-weight: 800; color: var(--text); margin-bottom: 4px; }
.approved-amount { font-size: 12px; color: #7eb87a; margin-bottom: 8px; }
.method-name { font-size: 12px; color: #888; margin-bottom: 8px; }
.time-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.time-label { color: #666; }
.time-value { color: #aaa; }
.reject-reason { margin-top: 8px; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(245,108,108,0.22); font-size: 12px; color: #f56c6c; line-height: 1.45; word-break: break-word; }
.order-remark { margin-top: 8px; font-size: 12px; color: #888; line-height: 1.45; }
.audit-title { margin: 0 0 12px; font-size: 14px; font-weight: 700; color: #ccc; }
.audit-track { display: flex; flex-direction: column; }
.audit-step { display: flex; gap: 8px; }
.audit-step-rail { flex: 0 0 10px; display: flex; flex-direction: column; align-items: center; padding-top: 4px; }
.audit-dot { width: 5px; height: 5px; border-radius: 50%; background: #555; flex-shrink: 0; }
.audit-line { flex: 1; width: 1px; min-height: 10px; margin: 3px 0; background: #2a2a2a; }
.audit-step-body { flex: 1; min-width: 0; padding-bottom: 12px; }
.audit-step:last-child .audit-step-body { padding-bottom: 0; }
.audit-step-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.audit-step-title { font-size: 12px; font-weight: 600; color: #ccc; }
.audit-step-time { font-size: 10px; color: #666; white-space: nowrap; }
.audit-step-actor { margin: 2px 0 0; font-size: 11px; color: #999; }
.audit-step-credited { margin: 3px 0 0; font-size: 11px; font-weight: 500; color: #7eb87a; }
.audit-step-note { margin: 4px 0 0; font-size: 11px; color: #888; line-height: 1.45; word-break: break-word; }
.audit-step-box { margin-top: 4px; padding: 6px 8px; border-radius: 6px; display: flex; flex-direction: column; gap: 1px; word-break: break-word; }
.audit-step-box--reject { border: 1px solid rgba(245,108,108,0.22); }
.audit-step-box-label { font-size: 10px; font-weight: 500; color: #c07070; }
.audit-step-box-text { font-size: 11px; color: #b08888; }
.audit-step--submitted .audit-dot { background: #c9a227; }
.audit-step--approved .audit-dot { background: #4cd964; }
.audit-step--rejected .audit-dot { background: #ff453a; }
.audit-step--revoked .audit-dot { background: #888; }
.audit-step--reopened .audit-dot { background: #c9a227; }
.reapply-btn {
display: block;
width: 100%;
margin-top: 4px;
padding: 12px;
font-size: 14px;
font-weight: 700;
border-radius: 10px;
cursor: pointer;
}
</style>