feat(player): PC端投注/资金详情弹窗化 + 详情页布局优化 - 新增 DesktopBetDetailModal / DesktopTxDetailModal 弹窗组件,列表点击弹窗展示详情 - 优化 PC 端注单详情、钱包交易详情、个人资料页的布局(双列网格、加宽 max-width) - 添加相关 i18n 三语翻译 key - DesktopMyBetsView / DesktopWalletView 列表项点击改为打开弹窗而非跳转页面
This commit is contained in:
390
apps/player/src/components/desktop/DesktopBetDetailModal.vue
Normal file
390
apps/player/src/components/desktop/DesktopBetDetailModal.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import api from '../../api';
|
||||||
|
import GoldSpinner from '../GoldSpinner.vue';
|
||||||
|
|
||||||
|
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;
|
||||||
|
score?: { ht: string | null; ft: string | null } | null;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detail = ref<BetDetail | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref(false);
|
||||||
|
|
||||||
|
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) loadDetail(no);
|
||||||
|
if (!vis) {
|
||||||
|
detail.value = null;
|
||||||
|
error.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string) {
|
||||||
|
const map: Record<string, string> = { WON: 'st-won', LOST: 'st-lost', PENDING: 'st-pending', PUSH: 'st-push' };
|
||||||
|
return map[status?.toUpperCase()] ?? 'st-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">{{ t('bet.detail_title') || '注单详情' }}</span>
|
||||||
|
<button type="button" class="modal-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="loading" class="modal-loading">
|
||||||
|
<GoldSpinner :size="32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="modal-error">
|
||||||
|
<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="info-rows">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('bet.bet_no') || '注单号' }}</span>
|
||||||
|
<span class="row-val mono">{{ detail.betNo }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('common.status') || '状态' }}</span>
|
||||||
|
<span class="row-val">
|
||||||
|
<span class="st-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('bet.stake') || '投注额' }}</span>
|
||||||
|
<span class="row-val">{{ detail.stake }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
|
||||||
|
<span class="row-val gold">{{ detail.potentialReturn || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('bet.placed_at') || '下注时间' }}</span>
|
||||||
|
<span class="row-val muted">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sel-section">
|
||||||
|
<div class="sel-section-title">{{ t('bet.selections') || '投注选项' }}</div>
|
||||||
|
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-item">
|
||||||
|
<div class="sel-item-main">
|
||||||
|
<span class="sel-item-match">{{ leg.matchTitle || t('bet.match') }}</span>
|
||||||
|
<span class="sel-item-pick">
|
||||||
|
{{ leg.marketLabel || '' }} · {{ translateSel(leg.selectionName || '') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="leg.score?.ft" class="sel-item-score">{{ t('history.ft') }}: {{ leg.score.ft }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="sel-item-odds">@{{ leg.odds }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="modal-empty">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--desktop-sidebar-bg, rgba(20, 20, 20, 0.95));
|
||||||
|
border: 1px solid var(--desktop-border, #262626);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--desktop-border, #262626);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-gold-soft, rgba(212,175,55,0.25));
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-light, #F0D875);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info rows */
|
||||||
|
.info-rows {
|
||||||
|
border-bottom: 1px solid var(--desktop-border, #262626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-val {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-val.gold { color: var(--primary-light, #F0D875); }
|
||||||
|
.row-val.muted { color: var(--text-muted); font-weight: 500; }
|
||||||
|
|
||||||
|
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
.st-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-won { background: rgba(52,199,89,0.12); color: #4cd964; }
|
||||||
|
.st-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
|
||||||
|
.st-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
|
||||||
|
.st-push { background: rgba(100,100,100,0.12); color: #aaa; }
|
||||||
|
.st-default { background: rgba(100,100,100,0.1); color: #888; }
|
||||||
|
|
||||||
|
/* Selections */
|
||||||
|
.sel-section { padding: 12px 16px 16px; }
|
||||||
|
|
||||||
|
.sel-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--desktop-border, #262626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.sel-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item-match {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item-pick {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item-score {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-item-odds {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-light, #F0D875);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.modal-fade-enter-active,
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fade-enter-from,
|
||||||
|
.modal-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -39,7 +39,7 @@ const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
|
|||||||
p === '/bets' ||
|
p === '/bets' ||
|
||||||
p.startsWith('/bets/') ||
|
p.startsWith('/bets/') ||
|
||||||
p.startsWith('/wallet') ||
|
p.startsWith('/wallet') ||
|
||||||
p === '/profile/cashbacks'
|
p.startsWith('/profile')
|
||||||
) {
|
) {
|
||||||
return 'account';
|
return 'account';
|
||||||
}
|
}
|
||||||
|
|||||||
289
apps/player/src/components/desktop/DesktopTxDetailModal.vue
Normal file
289
apps/player/src/components/desktop/DesktopTxDetailModal.vue
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import api from '../../api';
|
||||||
|
import GoldSpinner from '../GoldSpinner.vue';
|
||||||
|
import { formatMoney } from '../../utils/localeDisplay';
|
||||||
|
import { txTypeKey, txDisplayType, txSummaryLabel } from '../../utils/walletTx';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
transactionId: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
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;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detail = ref<TxDetail | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
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) loadDetail(id);
|
||||||
|
if (!vis) {
|
||||||
|
detail.value = null;
|
||||||
|
error.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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">{{ t('wallet.transaction_detail') || '交易详情' }}</span>
|
||||||
|
<button type="button" class="modal-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="loading" class="modal-loading">
|
||||||
|
<GoldSpinner :size="32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="modal-error">
|
||||||
|
<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="info-rows">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.type') || '类型' }}</span>
|
||||||
|
<span class="row-val">{{ txLabel(detail) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.amount') || '金额' }}</span>
|
||||||
|
<span class="row-val mono" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">{{ formatMoney(detail.amount, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.note') || '备注' }}</span>
|
||||||
|
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.balance_before') || '变动前余额' }}</span>
|
||||||
|
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.balance_after') || '变动后余额' }}</span>
|
||||||
|
<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="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.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="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.frozen_after') || '冻结后' }}</span>
|
||||||
|
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.time') || '时间' }}</span>
|
||||||
|
<span class="row-val muted">{{ new Date(detail.createdAt).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="row-label">{{ t('wallet.transaction_id') || '流水号' }}</span>
|
||||||
|
<span class="row-val mono muted">{{ detail.transactionId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="modal-empty">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--desktop-sidebar-bg, rgba(20, 20, 20, 0.95));
|
||||||
|
border: 1px solid var(--desktop-border, #262626);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--desktop-border, #262626);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-gold-soft, rgba(212,175,55,0.25));
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-light, #F0D875);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info rows */
|
||||||
|
.info-rows { }
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-val {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-val.pos { color: var(--primary-light, #F0D875); }
|
||||||
|
.row-val.neg { color: var(--danger, #FF453A); }
|
||||||
|
.row-val.muted { color: var(--text-muted); font-weight: 500; }
|
||||||
|
|
||||||
|
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.modal-fade-enter-active,
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fade-enter-from,
|
||||||
|
.modal-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -313,6 +313,9 @@ export default {
|
|||||||
recharge_btn: 'Deposit',
|
recharge_btn: 'Deposit',
|
||||||
recharge_history: 'Deposit history',
|
recharge_history: 'Deposit history',
|
||||||
cashbacks_tab: 'Cashback',
|
cashbacks_tab: 'Cashback',
|
||||||
|
detail_info: 'Details',
|
||||||
|
frozen_before: 'Frozen before',
|
||||||
|
frozen_after: 'Frozen after',
|
||||||
},
|
},
|
||||||
recharge: {
|
recharge: {
|
||||||
title: 'Recharge',
|
title: 'Recharge',
|
||||||
@@ -601,6 +604,9 @@ export default {
|
|||||||
placed_at: 'Placed at',
|
placed_at: 'Placed at',
|
||||||
detail_title: 'Bet details',
|
detail_title: 'Bet details',
|
||||||
selections: 'Selections',
|
selections: 'Selections',
|
||||||
|
summary: 'Bet summary',
|
||||||
|
legs_count: 'Legs',
|
||||||
|
back_to_list: 'Back to bet list',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
edit: 'Edit Profile',
|
edit: 'Edit Profile',
|
||||||
@@ -656,5 +662,15 @@ export default {
|
|||||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||||
|
qnav_bets_desc: 'View bet history',
|
||||||
|
qnav_wallet_desc: 'Wallet transactions',
|
||||||
|
qnav_cashback_desc: 'Cashback records',
|
||||||
|
qnav_recharge_desc: 'Deposit funds',
|
||||||
|
qnav_announcements_desc: 'View announcements',
|
||||||
|
qnav_edit_desc: 'Edit your profile',
|
||||||
|
security_tips: 'Security tips',
|
||||||
|
tip_1: 'Change your password regularly to keep your account secure',
|
||||||
|
tip_2: 'Never share your password with anyone',
|
||||||
|
tip_3: 'Contact customer service if you need help',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -325,6 +325,9 @@ export default {
|
|||||||
recharge_btn: 'Topup',
|
recharge_btn: 'Topup',
|
||||||
recharge_history: 'Sejarah topup',
|
recharge_history: 'Sejarah topup',
|
||||||
cashbacks_tab: 'Rebat',
|
cashbacks_tab: 'Rebat',
|
||||||
|
detail_info: 'Butiran',
|
||||||
|
frozen_before: 'Beku sebelum',
|
||||||
|
frozen_after: 'Beku selepas',
|
||||||
},
|
},
|
||||||
recharge: {
|
recharge: {
|
||||||
title: 'Topup',
|
title: 'Topup',
|
||||||
@@ -613,6 +616,9 @@ export default {
|
|||||||
placed_at: 'Masa pertaruhan',
|
placed_at: 'Masa pertaruhan',
|
||||||
detail_title: 'Butiran pertaruhan',
|
detail_title: 'Butiran pertaruhan',
|
||||||
selections: 'Pilihan',
|
selections: 'Pilihan',
|
||||||
|
summary: 'Ringkasan pertaruhan',
|
||||||
|
legs_count: 'Bilangan pilihan',
|
||||||
|
back_to_list: 'Kembali ke senarai pertaruhan',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
edit: 'Edit Profil',
|
edit: 'Edit Profil',
|
||||||
@@ -668,5 +674,15 @@ export default {
|
|||||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||||
|
qnav_bets_desc: 'Lihat sejarah pertaruhan',
|
||||||
|
qnav_wallet_desc: 'Transaksi dompet',
|
||||||
|
qnav_cashback_desc: 'Rekod rebat',
|
||||||
|
qnav_recharge_desc: 'Tambah dana',
|
||||||
|
qnav_announcements_desc: 'Lihat pengumuman',
|
||||||
|
qnav_edit_desc: 'Edit profil anda',
|
||||||
|
security_tips: 'Tip keselamatan',
|
||||||
|
tip_1: 'Tukar kata laluan secara berkala untuk keselamatan akaun',
|
||||||
|
tip_2: 'Jangan berkongsi kata laluan dengan sesiapa',
|
||||||
|
tip_3: 'Hubungi perkhidmatan pelanggan jika memerlukan bantuan',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ export default {
|
|||||||
recharge_btn: '充值',
|
recharge_btn: '充值',
|
||||||
recharge_history: '充值记录',
|
recharge_history: '充值记录',
|
||||||
cashbacks_tab: '返水明细',
|
cashbacks_tab: '返水明细',
|
||||||
|
detail_info: '详细信息',
|
||||||
|
frozen_before: '冻结前',
|
||||||
|
frozen_after: '冻结后',
|
||||||
},
|
},
|
||||||
recharge: {
|
recharge: {
|
||||||
title: '充值',
|
title: '充值',
|
||||||
@@ -601,6 +604,9 @@ export default {
|
|||||||
placed_at: '下注时间',
|
placed_at: '下注时间',
|
||||||
detail_title: '注单详情',
|
detail_title: '注单详情',
|
||||||
selections: '投注选项',
|
selections: '投注选项',
|
||||||
|
summary: '注单概要',
|
||||||
|
legs_count: '过关场数',
|
||||||
|
back_to_list: '返回注单列表',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
edit: '修改资料',
|
edit: '修改资料',
|
||||||
@@ -656,5 +662,15 @@ export default {
|
|||||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||||
|
qnav_bets_desc: '查看历史投注记录',
|
||||||
|
qnav_wallet_desc: '资金流水明细',
|
||||||
|
qnav_cashback_desc: '返水记录详情',
|
||||||
|
qnav_recharge_desc: '账户充值',
|
||||||
|
qnav_announcements_desc: '查看站内公告',
|
||||||
|
qnav_edit_desc: '修改个人资料',
|
||||||
|
security_tips: '安全提示',
|
||||||
|
tip_1: '定期更换密码以保护账户安全',
|
||||||
|
tip_2: '不要将密码透露给他人',
|
||||||
|
tip_3: '如需帮助请联系在线客服',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -120,6 +120,13 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-account-center > .desktop-records-page.profile-page,
|
||||||
|
.desktop-account-center > .desktop-records-page.edit-page {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-account-center > .desktop-account-page {
|
.desktop-account-center > .desktop-account-page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ function translateSel(name: string): string {
|
|||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legResultIcon(status?: string) {
|
||||||
|
switch (status?.toUpperCase()) {
|
||||||
|
case 'WON': return '✓';
|
||||||
|
case 'LOST': return '✗';
|
||||||
|
case 'PUSH': return '=';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function legResultClass(status?: string) {
|
||||||
|
switch (status?.toUpperCase()) {
|
||||||
|
case 'WON': return 'leg-won';
|
||||||
|
case 'LOST': return 'leg-lost';
|
||||||
|
case 'PUSH': return 'leg-push';
|
||||||
|
default: return 'leg-pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,39 +135,67 @@ function translateSel(name: string): string {
|
|||||||
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="detail" class="detail-layout">
|
<div v-else-if="detail" class="detail-grid">
|
||||||
<!-- Header info -->
|
<!-- Left column: selections -->
|
||||||
<div class="detail-card header-card">
|
<div class="detail-left">
|
||||||
<div class="bet-no">{{ detail.betNo }}</div>
|
<div class="detail-card">
|
||||||
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
<div class="card-title">{{ t('bet.selections') || '投注选项' }}</div>
|
||||||
<div class="amount-row">
|
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-row">
|
||||||
<div class="amount-item">
|
<div class="sel-row-left">
|
||||||
<span class="amount-label">{{ t('bet.stake') || '投注额' }}</span>
|
<span class="sel-index">{{ idx + 1 }}</span>
|
||||||
<span class="amount-val">{{ detail.stake }}</span>
|
<div class="sel-info">
|
||||||
</div>
|
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
|
||||||
<div class="amount-item">
|
<div class="sel-meta">
|
||||||
<span class="amount-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
|
<span class="sel-market">{{ leg.marketLabel || '' }}</span>
|
||||||
<span class="amount-val gold">{{ detail.potentialReturn || '-' }}</span>
|
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
|
||||||
</div>
|
<span v-if="leg.score?.ft" class="sel-score">{{ t('history.ft') }}: {{ leg.score.ft }}</span>
|
||||||
<div class="amount-item">
|
</div>
|
||||||
<span class="amount-label">{{ t('bet.placed_at') || '下注时间' }}</span>
|
</div>
|
||||||
<span class="amount-val date">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
|
</div>
|
||||||
|
<div class="sel-row-right">
|
||||||
|
<span class="sel-odds">@{{ leg.odds }}</span>
|
||||||
|
<span v-if="leg.resultStatus" class="leg-result" :class="legResultClass(leg.resultStatus)">
|
||||||
|
{{ legResultIcon(leg.resultStatus) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selections -->
|
<!-- Right column: summary -->
|
||||||
<div class="detail-card">
|
<div class="detail-right">
|
||||||
<div class="card-title">{{ t('bet.selections') || '投注选项' }}</div>
|
<div class="detail-card summary-card">
|
||||||
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-row">
|
<div class="card-title">{{ t('bet.summary') || '注单概要' }}</div>
|
||||||
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
|
<div class="summary-bet-no">
|
||||||
<div class="sel-meta">
|
<span class="summary-label">{{ t('bet.bet_no') || '注单号' }}</span>
|
||||||
<span class="sel-market">{{ leg.marketLabel || '' }}</span>
|
<span class="summary-value mono">{{ detail.betNo }}</span>
|
||||||
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
|
</div>
|
||||||
<span class="sel-odds">@{{ leg.odds }}</span>
|
<div class="summary-status">
|
||||||
<span v-if="leg.score?.ft" class="sel-score">({{ t('history.ft') }}: {{ leg.score.ft }})</span>
|
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-amounts">
|
||||||
|
<div class="summary-amount-item">
|
||||||
|
<span class="summary-amount-label">{{ t('bet.stake') || '投注额' }}</span>
|
||||||
|
<span class="summary-amount-val">{{ detail.stake }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-amount-item">
|
||||||
|
<span class="summary-amount-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
|
||||||
|
<span class="summary-amount-val gold">{{ detail.potentialReturn || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-amount-item">
|
||||||
|
<span class="summary-amount-label">{{ t('bet.placed_at') || '下注时间' }}</span>
|
||||||
|
<span class="summary-amount-val date">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-amount-item">
|
||||||
|
<span class="summary-amount-label">{{ t('bet.legs_count') || '过关场数' }}</span>
|
||||||
|
<span class="summary-amount-val">{{ detail.legs?.length ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RouterLink to="/bets" class="back-to-list-btn">
|
||||||
|
{{ t('bet.back_to_list') || '返回注单列表' }}
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,7 +217,15 @@ function translateSel(name: string): string {
|
|||||||
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
|
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||||
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
|
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
.detail-layout { display: flex; flex-direction: column; gap: 16px; max-width: 680px; }
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-left { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
.detail-right { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
.detail-card {
|
.detail-card {
|
||||||
background: var(--desktop-sidebar-bg);
|
background: var(--desktop-sidebar-bg);
|
||||||
@@ -180,36 +234,101 @@ function translateSel(name: string): string {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-card { display: flex; flex-direction: column; gap: 12px; }
|
.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 var(--desktop-border); }
|
||||||
|
|
||||||
.bet-no {
|
/* Selection rows */
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
.sel-row {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.sel-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.sel-row-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
|
||||||
|
.sel-index {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.04em;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
|
.sel-info { min-width: 0; }
|
||||||
|
.sel-match { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
||||||
|
.sel-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--text-muted); flex-wrap: wrap; }
|
||||||
|
.sel-name { color: var(--text); font-weight: 700; }
|
||||||
|
.sel-score { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.sel-row-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
.sel-odds { color: var(--primary-light); font-weight: 800; font-size: 14px; }
|
||||||
|
|
||||||
|
.leg-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.leg-won { background: rgba(52,199,89,0.15); color: #4cd964; }
|
||||||
|
.leg-lost { background: rgba(255,69,58,0.15); color: #ff453a; }
|
||||||
|
.leg-push { background: rgba(100,100,100,0.15); color: #aaa; }
|
||||||
|
.leg-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); font-size: 10px; }
|
||||||
|
|
||||||
|
/* Summary card */
|
||||||
|
.summary-card { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.summary-bet-no { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.summary-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.summary-value { font-size: 14px; font-weight: 700; color: var(--text); }
|
||||||
|
.mono { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.03em; }
|
||||||
|
|
||||||
|
.summary-status { display: flex; }
|
||||||
|
|
||||||
|
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||||
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
|
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
|
||||||
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
|
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
|
||||||
.status-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
|
.status-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
|
||||||
.status-push { background: rgba(100,100,100,0.12); color: #aaa; }
|
.status-push { background: rgba(100,100,100,0.12); color: #aaa; }
|
||||||
.status-default { background: rgba(100,100,100,0.1); color: #888; }
|
.status-default { background: rgba(100,100,100,0.1); color: #888; }
|
||||||
|
|
||||||
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
|
.summary-amounts { display: flex; flex-direction: column; gap: 14px; padding-top: 16px; border-top: 1px solid var(--desktop-border); }
|
||||||
.amount-item { display: flex; flex-direction: column; gap: 4px; }
|
.summary-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; }
|
.summary-amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
.summary-amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
.amount-val.gold { color: var(--primary-light); }
|
.summary-amount-val.gold { color: var(--primary-light); }
|
||||||
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
.summary-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 var(--desktop-border); }
|
.back-to-list-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--desktop-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.back-to-list-btn:hover { border-color: var(--border-gold-soft); color: var(--primary-light); background: rgba(212,175,55,0.06); }
|
||||||
|
|
||||||
.sel-row { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
|
@media (max-width: 900px) {
|
||||||
.sel-row:last-child { border-bottom: none; }
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
.sel-match { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
}
|
||||||
.sel-meta { display: flex; gap: 10px; align-items: center; 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>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onActivated } from 'vue';
|
import { ref, onMounted, onActivated } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||||
import Pagination from '../../components/desktop/Pagination.vue';
|
import Pagination from '../../components/desktop/Pagination.vue';
|
||||||
|
import DesktopBetDetailModal from '../../components/desktop/DesktopBetDetailModal.vue';
|
||||||
import { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
|
import { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
|
||||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||||
|
|
||||||
const COL_COUNT = 8;
|
const COL_COUNT = 8;
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
|
||||||
|
const detailModalVisible = ref(false);
|
||||||
|
const detailBetNo = ref<string | null>(null);
|
||||||
|
|
||||||
|
function openDetail(betNo: string) {
|
||||||
|
detailBetNo.value = betNo;
|
||||||
|
detailModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const items = ref<BetHistoryItem[]>([]);
|
const items = ref<BetHistoryItem[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
@@ -154,7 +161,7 @@ function statusLabel(status: string) {
|
|||||||
v-for="bet in items"
|
v-for="bet in items"
|
||||||
:key="bet.betNo"
|
:key="bet.betNo"
|
||||||
class="hover-row clickable-row"
|
class="hover-row clickable-row"
|
||||||
@click="router.push(`/bets/${bet.betNo}`)"
|
@click="openDetail(bet.betNo)"
|
||||||
>
|
>
|
||||||
<td class="id-cell">{{ bet.betNo }}</td>
|
<td class="id-cell">{{ bet.betNo }}</td>
|
||||||
<td>{{ bet.matchTitle || '-' }}</td>
|
<td>{{ bet.matchTitle || '-' }}</td>
|
||||||
@@ -187,6 +194,8 @@ function statusLabel(status: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DesktopBetDetailModal v-model:visible="detailModalVisible" :bet-no="detailBetNo" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const auth = useAuthStore();
|
|||||||
const { loadProfile, setAvatarKey, profileRaw, avatarUrl } = usePlayerProfile();
|
const { loadProfile, setAvatarKey, profileRaw, avatarUrl } = usePlayerProfile();
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const viewablePassword = ref('');
|
|
||||||
const passwordVisible = ref(false);
|
|
||||||
const phone = ref('');
|
const phone = ref('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const avatarModalOpen = ref(false);
|
const avatarModalOpen = ref(false);
|
||||||
@@ -35,11 +33,6 @@ const allowUsernameChange = computed(
|
|||||||
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
|
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const canTogglePassword = computed(() => !!viewablePassword.value);
|
|
||||||
const passwordInputType = computed(() =>
|
|
||||||
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayAvatarUrl = computed(() => {
|
const displayAvatarUrl = computed(() => {
|
||||||
if (avatarUrl.value) return avatarUrl.value;
|
if (avatarUrl.value) return avatarUrl.value;
|
||||||
const seed = profileRaw.value?.username ?? auth.user?.username;
|
const seed = profileRaw.value?.username ?? auth.user?.username;
|
||||||
@@ -49,7 +42,6 @@ const displayAvatarUrl = computed(() => {
|
|||||||
function syncFromProfile() {
|
function syncFromProfile() {
|
||||||
const user = profileRaw.value;
|
const user = profileRaw.value;
|
||||||
username.value = user?.username ?? auth.user?.username ?? '';
|
username.value = user?.username ?? auth.user?.username ?? '';
|
||||||
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
|
|
||||||
phone.value = user?.preferences?.phone ?? '';
|
phone.value = user?.preferences?.phone ?? '';
|
||||||
email.value = user?.preferences?.email ?? '';
|
email.value = user?.preferences?.email ?? '';
|
||||||
}
|
}
|
||||||
@@ -98,136 +90,146 @@ async function savePassword() {
|
|||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAvatarSelect(key: string | null) {
|
|
||||||
avatarModalOpen.value = false;
|
|
||||||
await setAvatarKey(key);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="desktop-account-page">
|
<div class="desktop-records-page edit-page">
|
||||||
<main class="account-main">
|
<header class="desktop-records-header">
|
||||||
<div class="edit-layout">
|
<div class="desktop-records-header-main header-row">
|
||||||
<div class="page-header">
|
<button type="button" class="back-btn" @click="router.push('/profile')">
|
||||||
<button type="button" class="back-btn" @click="router.back()">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
<polyline points="12 19 5 12 12 5"></polyline>
|
||||||
<polyline points="12 19 5 12 12 5"></polyline>
|
</svg>
|
||||||
</svg>
|
<span>{{ t('common.back') || '返回' }}</span>
|
||||||
<span>{{ t('common.back') || '返回' }}</span>
|
</button>
|
||||||
</button>
|
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
|
||||||
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Avatar section -->
|
<div class="edit-grid">
|
||||||
<div class="edit-card">
|
<!-- Left: avatar + basic -->
|
||||||
<div class="card-title">{{ t('profile.avatar') || '头像' }}</div>
|
<section class="edit-card">
|
||||||
<div class="avatar-row">
|
<div class="card-title">{{ t('profile.avatar') || '头像' }}</div>
|
||||||
<img
|
<div class="avatar-row">
|
||||||
v-if="displayAvatarUrl"
|
<img
|
||||||
:src="displayAvatarUrl"
|
v-if="displayAvatarUrl"
|
||||||
alt="avatar"
|
:src="displayAvatarUrl"
|
||||||
class="avatar-img"
|
alt="avatar"
|
||||||
/>
|
class="avatar-img"
|
||||||
<div v-else class="avatar-placeholder">
|
/>
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
<div v-else class="avatar-placeholder">
|
||||||
</div>
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-meta">
|
||||||
<button type="button" class="change-avatar-btn" @click="avatarModalOpen = true">
|
<button type="button" class="change-avatar-btn" @click="avatarModalOpen = true">
|
||||||
{{ t('profile.change_avatar') || '更换头像' }}
|
{{ t('profile.change_avatar') || '更换头像' }}
|
||||||
</button>
|
</button>
|
||||||
|
<p class="field-hint">{{ t('profile.avatar_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="edit-card edit-card--span">
|
||||||
|
<div class="card-title">{{ t('profile.basic_info') || '基础信息' }}</div>
|
||||||
|
|
||||||
|
<div v-if="message" class="success-msg">{{ message }}</div>
|
||||||
|
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">{{ t('profile.username') || '用户名' }}</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="field-input"
|
||||||
|
:disabled="!allowUsernameChange"
|
||||||
|
/>
|
||||||
|
<p v-if="!allowUsernameChange" class="field-hint">{{ t('profile.username_locked') || '用户名不可修改' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">{{ t('profile.phone') || '手机号' }}</label>
|
||||||
|
<input v-model="phone" type="text" class="field-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">{{ t('profile.email') || '邮箱' }}</label>
|
||||||
|
<input v-model="email" type="email" class="field-input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Basic info -->
|
<div class="card-actions">
|
||||||
<div class="edit-card">
|
|
||||||
<div class="card-title">{{ t('profile.basic_info') || '基础信息' }}</div>
|
|
||||||
|
|
||||||
<div v-if="message" class="success-msg">{{ message }}</div>
|
|
||||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">{{ t('profile.username') || '用户名' }}</label>
|
|
||||||
<input
|
|
||||||
v-model="username"
|
|
||||||
type="text"
|
|
||||||
class="field-input"
|
|
||||||
:disabled="!allowUsernameChange"
|
|
||||||
/>
|
|
||||||
<p v-if="!allowUsernameChange" class="field-hint">{{ t('profile.username_locked') || '用户名不可修改' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">{{ t('profile.phone') || '手机号' }}</label>
|
|
||||||
<input v-model="phone" type="text" class="field-input" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">Email</label>
|
|
||||||
<input v-model="email" type="email" class="field-input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="save-btn" :disabled="saving" @click="saveProfile">
|
<button type="button" class="save-btn" :disabled="saving" @click="saveProfile">
|
||||||
<GoldSpinner v-if="saving" :size="18" />
|
<GoldSpinner v-if="saving" :size="18" />
|
||||||
<span v-else>{{ t('common.save') || '保存' }}</span>
|
<span v-else>{{ t('common.save') || '保存' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Password change -->
|
<!-- Password -->
|
||||||
<div v-if="allowPasswordChange" class="edit-card">
|
<section v-if="allowPasswordChange" class="edit-card edit-card--span">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
{{ t('profile.change_password') || '修改密码' }}
|
{{ t('profile.change_password') || '修改密码' }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="toggle-btn"
|
class="toggle-btn"
|
||||||
@click="passwordChangeOpen = !passwordChangeOpen"
|
@click="passwordChangeOpen = !passwordChangeOpen"
|
||||||
>
|
>
|
||||||
{{ passwordChangeOpen ? (t('common.collapse') || '收起') : (t('common.expand') || '展开') }}
|
{{ passwordChangeOpen ? (t('common.collapse') || '收起') : (t('common.expand') || '展开') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="passwordChangeOpen" class="field-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">{{ t('profile.old_password') || '当前密码' }}</label>
|
||||||
|
<input v-model="oldPassword" type="password" class="field-input" autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
<div v-show="passwordChangeOpen" class="field-group">
|
<label class="field-label">{{ t('profile.new_password') || '新密码' }}</label>
|
||||||
<div class="field">
|
<input v-model="newPassword" type="password" class="field-input" autocomplete="new-password" />
|
||||||
<label class="field-label">{{ t('profile.old_password') || '当前密码' }}</label>
|
</div>
|
||||||
<input v-model="oldPassword" type="password" class="field-input" autocomplete="current-password" />
|
<div class="field">
|
||||||
</div>
|
<label class="field-label">{{ t('profile.confirm_password') || '确认密码' }}</label>
|
||||||
<div class="field">
|
<input v-model="confirmPassword" type="password" class="field-input" autocomplete="new-password" />
|
||||||
<label class="field-label">{{ t('profile.new_password') || '新密码' }}</label>
|
|
||||||
<input v-model="newPassword" type="password" class="field-input" autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">{{ t('profile.confirm_password') || '确认密码' }}</label>
|
|
||||||
<input v-model="confirmPassword" type="password" class="field-input" autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<button type="button" class="save-btn" :disabled="saving" @click="savePassword">
|
|
||||||
<GoldSpinner v-if="saving" :size="18" />
|
|
||||||
<span v-else>{{ t('profile.change_password') || '修改密码' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-show="passwordChangeOpen" class="card-actions">
|
||||||
|
<button type="button" class="save-btn" :disabled="saving" @click="savePassword">
|
||||||
|
<GoldSpinner v-if="saving" :size="18" />
|
||||||
|
<span v-else>{{ t('profile.change_password') || '修改密码' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PlayerAvatarModal
|
<PlayerAvatarModal
|
||||||
:open="avatarModalOpen"
|
:open="avatarModalOpen"
|
||||||
:model-value="profileRaw?.preferences?.avatarKey ?? null"
|
:model-value="profileRaw?.preferences?.avatarKey ?? null"
|
||||||
@close="avatarModalOpen = false"
|
@close="avatarModalOpen = false"
|
||||||
@confirm="(key) => { avatarModalOpen = false; if (key !== undefined) setAvatarKey(key); }"
|
@confirm="(key) => { avatarModalOpen = false; if (key !== undefined) setAvatarKey(key); }"
|
||||||
/>
|
/>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
.edit-page {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
.header-row {
|
||||||
.page-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
@@ -251,32 +253,32 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn svg {
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover svg {
|
.back-btn:hover svg {
|
||||||
transform: translateX(-2px);
|
transform: translateX(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
|
.back-btn svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-layout {
|
.edit-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 320px 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
max-width: 560px;
|
align-items: start;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-card {
|
.edit-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-card--span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -297,17 +299,24 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-img {
|
.avatar-img {
|
||||||
width: 64px;
|
width: 72px;
|
||||||
height: 64px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid var(--border-gold-soft);
|
border: 2px solid var(--border-gold-soft);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 64px;
|
width: 72px;
|
||||||
height: 64px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -315,9 +324,11 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 2px solid var(--desktop-border);
|
border: 2px solid var(--desktop-border);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-avatar-btn {
|
.change-avatar-btn {
|
||||||
|
align-self: flex-start;
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--desktop-border);
|
border: 1px solid var(--desktop-border);
|
||||||
@@ -329,11 +340,24 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-avatar-btn:hover { border-color: var(--border-gold-soft); color: var(--primary-light); }
|
.change-avatar-btn:hover {
|
||||||
|
border-color: var(--border-gold-soft);
|
||||||
|
color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
.field-group { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -355,13 +379,45 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input:focus { border-color: var(--border-gold-soft); }
|
.field-input:focus {
|
||||||
.field-input:disabled { opacity: 0.45; cursor: not-allowed; }
|
border-color: var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.field-hint { font-size: 11px; color: var(--text-muted); margin: 0; }
|
.field-input:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.success-msg { padding: 10px 14px; border-radius: 6px; background: rgba(52,199,89,0.1); color: #4cd964; font-size: 13px; font-weight: 700; margin-bottom: 14px; }
|
.field-hint {
|
||||||
.error-msg { padding: 10px 14px; border-radius: 6px; background: rgba(255,69,58,0.1); color: #ff453a; font-size: 13px; font-weight: 700; margin-bottom: 14px; }
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(52, 199, 89, 0.1);
|
||||||
|
color: #4cd964;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 69, 58, 0.1);
|
||||||
|
color: #ff453a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -379,8 +435,14 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn:hover:not(:disabled) { opacity: 0.88; }
|
.save-btn:hover:not(:disabled) {
|
||||||
.save-btn:disabled { opacity: 0.45; cursor: default; }
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -392,4 +454,20 @@ async function onAvatarSelect(key: string | null) {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.edit-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onActivated, computed } from 'vue';
|
import { ref, onMounted, onActivated, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, RouterLink } from 'vue-router';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||||
import LocaleFlag from '../../components/LocaleFlag.vue';
|
import LocaleFlag from '../../components/LocaleFlag.vue';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { useAppLocale } from '../../composables/useAppLocale';
|
import { useAppLocale } from '../../composables/useAppLocale';
|
||||||
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||||
import { useInboxFeature } from '../../composables/useInboxFeature';
|
|
||||||
import { formatMoney } from '../../utils/localeDisplay';
|
import { formatMoney } from '../../utils/localeDisplay';
|
||||||
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
||||||
import walletBg from '../../assets/images/wallet-bg.webp';
|
import walletBg from '../../assets/images/wallet-bg.webp';
|
||||||
@@ -18,7 +17,6 @@ const router = useRouter();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||||
const { hubRoute } = useInboxFeature();
|
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
@@ -69,137 +67,225 @@ const balanceDisplay = computed(() =>
|
|||||||
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
|
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const balanceAmountClass = computed(() => {
|
||||||
|
const len = balanceDisplay.value.length;
|
||||||
|
if (len <= 10) return 'balance-amount--xl';
|
||||||
|
if (len <= 13) return 'balance-amount--lg';
|
||||||
|
if (len <= 16) return 'balance-amount--md';
|
||||||
|
if (len <= 19) return 'balance-amount--sm';
|
||||||
|
return 'balance-amount--xs';
|
||||||
|
});
|
||||||
|
|
||||||
|
const quickLinks = computed(() => [
|
||||||
|
{ to: '/bets', label: t('nav.bet_history'), desc: t('profile.qnav_bets_desc') },
|
||||||
|
{ to: '/wallet', label: t('nav.wallet'), desc: t('profile.qnav_wallet_desc') },
|
||||||
|
{ to: '/wallet/recharge', label: t('recharge.title'), desc: t('profile.qnav_recharge_desc') },
|
||||||
|
{ to: '/wallet/cashbacks', label: t('wallet.cashbacks_tab'), desc: t('profile.qnav_cashback_desc') },
|
||||||
|
{ to: '/announcements', label: t('nav.announcements'), desc: t('profile.qnav_announcements_desc') },
|
||||||
|
{ to: '/profile/edit', label: t('profile.edit'), desc: t('profile.qnav_edit_desc') },
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="desktop-account-page">
|
<div class="desktop-records-page profile-page">
|
||||||
<main class="account-main">
|
<header class="desktop-records-header">
|
||||||
<div class="profile-layout">
|
<div class="desktop-records-header-main">
|
||||||
<div class="page-header">
|
<h1 class="page-title">{{ t('nav.profile') }}</h1>
|
||||||
<h1 class="page-title">{{ t('nav.profile') }}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">
|
|
||||||
<GoldSpinner :size="36" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="error" class="error-state">
|
|
||||||
<p>{{ t('common.load_failed') }}</p>
|
|
||||||
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<!-- Wallet Banner -->
|
|
||||||
<div class="wallet-banner">
|
|
||||||
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
|
||||||
<div class="wallet-banner-scrim" aria-hidden="true" />
|
|
||||||
|
|
||||||
<div class="card-overlay">
|
|
||||||
<div class="bank-card-top">
|
|
||||||
<div class="bank-card-top-left">
|
|
||||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
|
||||||
<div class="bank-card-brand-text">
|
|
||||||
<span class="brand-name">THEBET365</span>
|
|
||||||
<span class="bank-card-type">MEMBER</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-link to="/wallet/recharge" class="recharge-btn">
|
|
||||||
<span class="recharge-icon">+</span>
|
|
||||||
<span>{{ t('recharge.title') }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bank-card-balance">
|
|
||||||
<span class="bank-card-label">{{ t('wallet.available') || '可用余额' }}</span>
|
|
||||||
<p class="bank-card-number">{{ balanceDisplay }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bank-card-footer">
|
|
||||||
<div class="bank-card-field">
|
|
||||||
<span class="bank-card-label">{{ t('profile.username') || '持卡人' }}</span>
|
|
||||||
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bank-card-field bank-card-field--center">
|
|
||||||
<span class="bank-card-label">{{ t('wallet.cashback_total') || '累计返水' }}</span>
|
|
||||||
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bank-card-field bank-card-field--right">
|
|
||||||
<span class="bank-card-label">{{ t('wallet.frozen') || '冻结' }}</span>
|
|
||||||
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account info -->
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-header">{{ t('profile.account') || '账户信息' }}</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">{{ t('profile.username') || '用户名' }}</span>
|
|
||||||
<span class="info-val">{{ profileRaw?.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">{{ t('profile.phone') || '手机号' }}</span>
|
|
||||||
<span class="info-val">{{ profileRaw?.preferences?.phone || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-actions">
|
|
||||||
<router-link to="/profile/edit" class="edit-btn">{{ t('profile.edit') }}</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Language -->
|
|
||||||
<div class="lang-card">
|
|
||||||
<div class="info-header">{{ t('profile.language') }}</div>
|
|
||||||
<div class="lang-group">
|
|
||||||
<button
|
|
||||||
v-for="item in locales"
|
|
||||||
:key="item.code"
|
|
||||||
type="button"
|
|
||||||
class="lang-opt"
|
|
||||||
:class="{ active: locale === item.code }"
|
|
||||||
@click="setLocale(item.code)"
|
|
||||||
>
|
|
||||||
<LocaleFlag :locale="item.code" :size="14" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logout -->
|
|
||||||
<button type="button" class="logout-btn" @click="logout">
|
|
||||||
{{ t('auth.logout') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="desktop-records-actions">
|
||||||
|
<RouterLink to="/profile/edit" class="desktop-records-action-btn">
|
||||||
|
{{ t('profile.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<button type="button" class="logout-btn" @click="logout">
|
||||||
|
{{ t('auth.logout') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<GoldSpinner :size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<p>{{ t('common.load_failed') }}</p>
|
||||||
|
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Wallet card -->
|
||||||
|
<div class="wallet-banner">
|
||||||
|
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
||||||
|
<div class="wallet-banner-scrim" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="card-overlay">
|
||||||
|
<div class="bank-card-top">
|
||||||
|
<div class="bank-card-top-left">
|
||||||
|
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||||
|
<div class="bank-card-brand-text">
|
||||||
|
<span class="brand-name">THEBET365</span>
|
||||||
|
<span class="bank-card-type">MEMBER</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/wallet/recharge" class="recharge-btn">
|
||||||
|
<span class="recharge-icon">+</span>
|
||||||
|
<span>{{ t('recharge.title') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bank-card-balance">
|
||||||
|
<span class="bank-card-label">{{ t('wallet.available') }}</span>
|
||||||
|
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bank-card-footer">
|
||||||
|
<div class="bank-card-field">
|
||||||
|
<span class="bank-card-label">{{ t('profile.username') }}</span>
|
||||||
|
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bank-card-field bank-card-field--center">
|
||||||
|
<span class="bank-card-label">{{ t('wallet.cashback_total') }}</span>
|
||||||
|
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bank-card-field bank-card-field--right">
|
||||||
|
<span class="bank-card-label">{{ t('wallet.frozen') }}</span>
|
||||||
|
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-column: account + language -->
|
||||||
|
<div class="profile-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">{{ t('profile.account') }}</h2>
|
||||||
|
<RouterLink to="/profile/edit" class="panel-link">{{ t('profile.edit') }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
<dl class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>{{ t('profile.username') }}</dt>
|
||||||
|
<dd>{{ profileRaw?.username }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>{{ t('profile.phone') }}</dt>
|
||||||
|
<dd>{{ profileRaw?.preferences?.phone || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<dt>{{ t('profile.email') }}</dt>
|
||||||
|
<dd>{{ profileRaw?.preferences?.email || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">{{ t('profile.language') }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="lang-group">
|
||||||
|
<button
|
||||||
|
v-for="item in locales"
|
||||||
|
:key="item.code"
|
||||||
|
type="button"
|
||||||
|
class="lang-opt"
|
||||||
|
:class="{ active: locale === item.code }"
|
||||||
|
@click="setLocale(item.code)"
|
||||||
|
>
|
||||||
|
<LocaleFlag :locale="item.code" :size="16" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick links: desktop density -->
|
||||||
|
<section class="panel quick-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">{{ t('profile.quick_nav') }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="quick-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="link in quickLinks"
|
||||||
|
:key="link.to"
|
||||||
|
:to="link.to"
|
||||||
|
class="quick-card"
|
||||||
|
>
|
||||||
|
<span class="quick-label">{{ link.label }}</span>
|
||||||
|
<span class="quick-desc">{{ link.desc }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
.profile-page {
|
||||||
|
overflow-y: auto;
|
||||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
height: auto;
|
||||||
.page-header { margin-bottom: 24px; }
|
max-height: 100%;
|
||||||
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
|
|
||||||
.loading-state { display: flex; justify-content: center; align-items: center; padding: 80px 0; }
|
|
||||||
.error-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 80px 20px; color: var(--text-muted); }
|
|
||||||
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
|
|
||||||
|
|
||||||
.profile-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 69, 58, 0.35);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(255, 69, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-light);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— Wallet card —— */
|
||||||
.wallet-banner {
|
.wallet-banner {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 21 / 9;
|
aspect-ratio: 21 / 9;
|
||||||
border-radius: 16px;
|
max-height: 220px;
|
||||||
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow:
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.4),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wallet-banner::after {
|
.wallet-banner::after {
|
||||||
@@ -219,6 +305,7 @@ const balanceDisplay = computed(() =>
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center center;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
filter: brightness(0.86);
|
filter: brightness(0.86);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -230,7 +317,12 @@ const balanceDisplay = computed(() =>
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.18) 42%, rgba(0,0,0,0.46) 100%);
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 0, 0, 0.4) 0%,
|
||||||
|
rgba(0, 0, 0, 0.18) 42%,
|
||||||
|
rgba(0, 0, 0, 0.46) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-overlay {
|
.card-overlay {
|
||||||
@@ -239,30 +331,37 @@ const balanceDisplay = computed(() =>
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px;
|
padding: 20px 28px 18px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-top {
|
.bank-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-top-left {
|
.bank-card-top-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-brand-text {
|
.bank-card-brand-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
@@ -270,17 +369,32 @@ const balanceDisplay = computed(() =>
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
|
line-height: 1;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-type {
|
.bank-card-type {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
color: rgba(212, 175, 55, 0.8);
|
color: rgba(212, 175, 55, 0.8);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-card-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
line-height: 1.3;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharge-btn {
|
.recharge-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -291,42 +405,59 @@ const balanceDisplay = computed(() =>
|
|||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharge-btn:hover { background: rgba(212, 175, 55, 0.15); }
|
.recharge-btn:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.bank-card-balance {
|
.bank-card-balance {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
min-height: 0;
|
||||||
}
|
padding: 4px 0;
|
||||||
|
gap: 6px;
|
||||||
.bank-card-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-number {
|
.bank-card-number {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 38px;
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-amount--xl { font-size: 42px; }
|
||||||
|
.balance-amount--lg { font-size: 36px; }
|
||||||
|
.balance-amount--md { font-size: 30px; }
|
||||||
|
.balance-amount--sm { font-size: 24px; }
|
||||||
|
.balance-amount--xs { font-size: 20px; }
|
||||||
|
|
||||||
.bank-card-footer {
|
.bank-card-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-top: 16px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +465,7 @@ const balanceDisplay = computed(() =>
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-field--center { text-align: center; }
|
.bank-card-field--center { text-align: center; }
|
||||||
@@ -344,67 +476,110 @@ const balanceDisplay = computed(() =>
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-stat {
|
.bank-card-stat {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card, .lang-card {
|
/* —— Panels —— */
|
||||||
|
.profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 20px;
|
padding: 18px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-header {
|
.panel-header {
|
||||||
font-size: 11px;
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
color: var(--text-muted);
|
justify-content: space-between;
|
||||||
text-transform: uppercase;
|
gap: 12px;
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid var(--desktop-border);
|
border-bottom: 1px solid var(--desktop-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.panel-title {
|
||||||
display: flex;
|
margin: 0;
|
||||||
gap: 12px;
|
font-size: 11px;
|
||||||
align-items: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label { width: 80px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }
|
|
||||||
.info-val { flex: 1; font-size: 14px; font-weight: 700; color: var(--text); }
|
|
||||||
.info-actions { padding-top: 14px; }
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 7px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--border-gold-soft);
|
|
||||||
color: var(--primary-light);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
color: var(--text-muted);
|
||||||
transition: background 0.2s;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn:hover { background: rgba(212,175,55,0.08); }
|
.panel-link {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.lang-group { display: flex; gap: 8px; flex-wrap: wrap; }
|
.panel-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item dt {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.lang-opt {
|
.lang-opt {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 7px 14px;
|
padding: 10px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--desktop-border);
|
border: 1px solid var(--desktop-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -415,25 +590,72 @@ const balanceDisplay = computed(() =>
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-opt:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.lang-opt.active {
|
.lang-opt.active {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
background: rgba(212,175,55,0.08);
|
background: rgba(212, 175, 55, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.quick-grid {
|
||||||
display: block;
|
display: grid;
|
||||||
width: 100%;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
padding: 12px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--desktop-border);
|
border: 1px solid var(--desktop-border);
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
color: var(--danger);
|
text-decoration: none;
|
||||||
font-size: 14px;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover { background: rgba(255,69,58,0.06); }
|
.quick-card:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.06);
|
||||||
|
border-color: var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.wallet-banner {
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount--xl { font-size: 32px; }
|
||||||
|
.balance-amount--lg { font-size: 28px; }
|
||||||
|
.balance-amount--md { font-size: 24px; }
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,29 +68,36 @@ function txLabel(tx: TxDetail): string {
|
|||||||
<template>
|
<template>
|
||||||
<div class="desktop-account-page">
|
<div class="desktop-account-page">
|
||||||
<main class="account-main">
|
<main class="account-main">
|
||||||
<div class="detail-layout">
|
<div class="page-header">
|
||||||
<div class="page-header">
|
<button type="button" class="back-btn" @click="goBack">
|
||||||
<button type="button" class="back-btn" @click="goBack">
|
‹ {{ t('common.back') || '返回' }}
|
||||||
‹ {{ t('common.back') || '返回' }}
|
</button>
|
||||||
</button>
|
<h1 class="page-title">{{ t('wallet.transaction_detail') || '交易详情' }}</h1>
|
||||||
<h1 class="page-title">{{ t('wallet.transaction_detail') || '交易详情' }}</h1>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<GoldSpinner :size="36" />
|
<GoldSpinner :size="36" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="error-state">
|
<div v-else-if="error" class="error-state">
|
||||||
<p>{{ t('common.load_failed') }}</p>
|
<p>{{ t('common.load_failed') }}</p>
|
||||||
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="detail" class="detail-card">
|
<div v-else-if="detail" class="detail-grid">
|
||||||
|
<!-- Left: amount hero card -->
|
||||||
|
<div class="detail-card hero-card">
|
||||||
|
<div class="hero-type-label">{{ txLabel(detail) }}</div>
|
||||||
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
|
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
|
||||||
{{ formatMoney(detail.amount, locale) }}
|
{{ formatMoney(detail.amount, locale) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="type-label">{{ txLabel(detail) }}</div>
|
<div class="hero-summary">{{ txSummaryLabel(detail, t) || '-' }}</div>
|
||||||
|
<div class="hero-time">{{ new Date(detail.createdAt).toLocaleString() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: detail rows -->
|
||||||
|
<div class="detail-card rows-card">
|
||||||
|
<div class="card-title">{{ t('wallet.detail_info') || '详细信息' }}</div>
|
||||||
<div class="detail-rows">
|
<div class="detail-rows">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="row-label">{{ t('wallet.type') || '类型' }}</span>
|
<span class="row-label">{{ t('wallet.type') || '类型' }}</span>
|
||||||
@@ -100,6 +107,10 @@ function txLabel(tx: TxDetail): string {
|
|||||||
<span class="row-label">{{ t('wallet.note') || '备注' }}</span>
|
<span class="row-label">{{ t('wallet.note') || '备注' }}</span>
|
||||||
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
|
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="row-label">{{ t('wallet.amount') || '金额' }}</span>
|
||||||
|
<span class="row-val mono" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">{{ formatMoney(detail.amount, locale) }}</span>
|
||||||
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="row-label">{{ t('wallet.balance_before') || '变动前余额' }}</span>
|
<span class="row-label">{{ t('wallet.balance_before') || '变动前余额' }}</span>
|
||||||
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
|
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
|
||||||
@@ -126,9 +137,9 @@ function txLabel(tx: TxDetail): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -138,13 +149,6 @@ function txLabel(tx: TxDetail): string {
|
|||||||
|
|
||||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
||||||
|
|
||||||
.detail-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
|
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
|
||||||
.back-btn { background: none; border: none; color: var(--text-muted); font-size: 14px; font-weight: 700; cursor: pointer; padding: 0; }
|
.back-btn { background: none; border: none; color: var(--text-muted); font-size: 14px; font-weight: 700; cursor: pointer; padding: 0; }
|
||||||
.back-btn:hover { color: var(--text); }
|
.back-btn:hover { color: var(--text); }
|
||||||
@@ -154,6 +158,13 @@ function txLabel(tx: TxDetail): string {
|
|||||||
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
|
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||||
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
|
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-card {
|
.detail-card {
|
||||||
background: var(--desktop-sidebar-bg);
|
background: var(--desktop-sidebar-bg);
|
||||||
border: 1px solid var(--desktop-border);
|
border: 1px solid var(--desktop-border);
|
||||||
@@ -161,27 +172,53 @@ function txLabel(tx: TxDetail): string {
|
|||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero card (left) */
|
||||||
|
.hero-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-type-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
.amount-hero {
|
.amount-hero {
|
||||||
font-size: 36px;
|
font-size: 42px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
margin-bottom: 6px;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-hero.pos { color: var(--primary-light); }
|
.amount-hero.pos { color: var(--primary-light); }
|
||||||
.amount-hero.neg { color: var(--danger); }
|
.amount-hero.neg { color: var(--danger); }
|
||||||
|
|
||||||
.type-label {
|
.hero-summary {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text);
|
||||||
margin-bottom: 24px;
|
max-width: 280px;
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--desktop-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rows card (right) */
|
||||||
|
.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 var(--desktop-border); }
|
||||||
|
|
||||||
.detail-rows { display: flex; flex-direction: column; gap: 0; }
|
.detail-rows { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
@@ -193,9 +230,13 @@ function txLabel(tx: TxDetail): string {
|
|||||||
|
|
||||||
.detail-row:last-child { border-bottom: none; }
|
.detail-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.row-label { width: 120px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }
|
.row-label { width: 130px; 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 { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); word-break: break-all; }
|
||||||
.row-val.pos { color: var(--primary-light); }
|
.row-val.pos { color: var(--primary-light); }
|
||||||
.row-val.neg { color: var(--danger); }
|
.row-val.neg { color: var(--danger); }
|
||||||
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; letter-spacing: 0.03em; }
|
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; letter-spacing: 0.03em; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onActivated } from 'vue';
|
import { ref, onMounted, onActivated } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter, RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||||
import Pagination from '../../components/desktop/Pagination.vue';
|
import Pagination from '../../components/desktop/Pagination.vue';
|
||||||
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
||||||
|
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
|
||||||
import { formatMoney } from '../../utils/localeDisplay';
|
import { formatMoney } from '../../utils/localeDisplay';
|
||||||
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
|
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
|
||||||
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
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 = {
|
type Transaction = {
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
@@ -113,7 +121,7 @@ onActivated(() => fetchData(1));
|
|||||||
v-for="tx in items"
|
v-for="tx in items"
|
||||||
:key="tx.transactionId"
|
:key="tx.transactionId"
|
||||||
class="hover-row clickable-row"
|
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="type-cell">{{ txLabel(tx) }}</td>
|
||||||
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
|
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
|
||||||
@@ -139,6 +147,8 @@ onActivated(() => fetchData(1));
|
|||||||
<span>{{ t('wallet.no_records') }}</span>
|
<span>{{ t('wallet.no_records') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DesktopTxDetailModal v-model:visible="detailModalVisible" :transaction-id="detailTxId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user