Compare commits
16 Commits
49eb3cb7e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7dd165bc | ||
|
|
52529384e3 | ||
|
|
41dc92a0de | ||
|
|
1fb41fc18e | ||
|
|
394c28f08f | ||
|
|
b02e79785e | ||
|
|
375d352df2 | ||
|
|
091dc5c15c | ||
|
|
7ea7deeb52 | ||
|
|
4a95793d21 | ||
|
|
cca74310ed | ||
| ebcfaad264 | |||
| fa42529977 | |||
| 3bb4dd9b23 | |||
| 8000c23418 | |||
| 66ac69c7a7 |
@@ -22,4 +22,7 @@ apps/api/dist
|
||||
mcps
|
||||
assets
|
||||
agent-transcripts
|
||||
.agents
|
||||
.cursor
|
||||
.ui-ux-pro-max-skill
|
||||
**/.vite
|
||||
|
||||
1
.ui-ux-pro-max-skill
Submodule
@@ -90,6 +90,14 @@
|
||||
- 管理端切页慢的分析与优化任务见 `docs/admin-page-switch-performance.md`。
|
||||
- **不要**把 `apps/player/dist/` 提交进 Git;生产由 Docker 构建生成静态资源。
|
||||
|
||||
### 玩家端主题生图约束
|
||||
|
||||
- 生成 `apps/player` 主题图片资源时,必须先查看并参考现有同场景资源,优先保持当前暗金足球主题一致,不要另起一套风格。
|
||||
- PC 首页/赛事详情/弹窗类赛事面板应优先对齐 `apps/player/src/assets/images/generated/pc-featured-match-bg-v1.webp`:暗金球场、中央 V 型光线、薄金色边框、低亮度背景、内容区域干净。
|
||||
- 弹窗资源必须是“带完整边框的面板图”,不能只是普通背景图;四边边框要在图片内完整可见,中心区域要足够暗且简洁,保证文字可读。
|
||||
- 避免生成过度写实、照片质感过重、厚重金属、强眩光、烟雾、人物、旗帜、logo、文字、数字;也不要生成与主题不一致的纯矢量科幻线框风。
|
||||
- 生成资源默认输出 WebP,控制体积,文件放在 `apps/player/src/assets/images/generated/`,命名带清晰场景和版本号(如 `modal-gold-frame-v4.webp`)。
|
||||
|
||||
## i18n 改文案流程
|
||||
|
||||
支持语言均为 **zh-CN / en-US / ms-MY**(马来语)。改文案须**三语同步**,不要只改中文。
|
||||
@@ -209,4 +217,3 @@
|
||||
- 不要提交 `.env`、`.env.docker`、镜像 tar、`apps/*/dist/`。
|
||||
- 改 player 主题相关文件时,先确认当前分支是 `main` 还是 `theme-`*,避免用错 CSS 变量(如 theme-2 无 `--gold`)。
|
||||
- 管理端改列表页性能时,参考 `docs/admin-page-switch-performance.md` 任务清单,优先 KeepAlive + 缓存而非盲目减 API 字段。
|
||||
|
||||
|
||||
@@ -436,6 +436,15 @@ export class PlayerController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('deposit-orders/:id')
|
||||
async myDepositOrder(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const order = await this.deposit.getPlayerDepositOrder(userId, BigInt(id));
|
||||
return jsonResponse(order);
|
||||
}
|
||||
|
||||
@Get('deposit-orders/:id/audit-logs')
|
||||
async myDepositOrderAuditLogs(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
|
||||
@@ -416,6 +416,85 @@ export class DepositService {
|
||||
return map;
|
||||
}
|
||||
|
||||
private formatPlayerDepositOrder(
|
||||
o: {
|
||||
id: bigint;
|
||||
orderNo: string;
|
||||
paymentMethodId: bigint;
|
||||
methodType: string;
|
||||
amount: Decimal;
|
||||
screenshotUrl: string;
|
||||
status: string;
|
||||
approvedAmount: Decimal | null;
|
||||
rejectReason: string | null;
|
||||
remark: string | null;
|
||||
createdAt: Date;
|
||||
reviewedAt: Date | null;
|
||||
paymentMethod: {
|
||||
bankName: string | null;
|
||||
usdtAddress: string | null;
|
||||
displayName: string | null;
|
||||
methodType: string;
|
||||
} | null;
|
||||
},
|
||||
auditLogs: Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
statusBefore: string | null;
|
||||
statusAfter: string;
|
||||
amount: string | null;
|
||||
approvedAmount: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
paymentMethodId: o.paymentMethodId.toString(),
|
||||
methodType: o.methodType,
|
||||
amount: o.amount.toString(),
|
||||
screenshotUrl: o.screenshotUrl,
|
||||
status: o.status,
|
||||
approvedAmount: o.approvedAmount?.toString() ?? null,
|
||||
rejectReason: o.rejectReason,
|
||||
remark: o.remark,
|
||||
createdAt: o.createdAt,
|
||||
reviewedAt: o.reviewedAt,
|
||||
paymentMethodName:
|
||||
o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
|
||||
auditLogs,
|
||||
};
|
||||
}
|
||||
|
||||
async getPlayerDepositOrder(playerId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findFirst({
|
||||
where: { id: orderId, playerId },
|
||||
include: {
|
||||
paymentMethod: {
|
||||
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
|
||||
const auditMap = await this.attachPlayerAuditLogs([order]);
|
||||
const auditLogs = (auditMap.get(order.id.toString()) ?? []).map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
actorType: log.actorType,
|
||||
statusBefore: log.statusBefore,
|
||||
statusAfter: log.statusAfter,
|
||||
amount: log.amount,
|
||||
approvedAmount: log.approvedAmount,
|
||||
remark: log.remark,
|
||||
createdAt: log.createdAt,
|
||||
}));
|
||||
|
||||
return this.formatPlayerDepositOrder(order, auditLogs);
|
||||
}
|
||||
|
||||
async createDepositOrder(
|
||||
playerId: bigint,
|
||||
paymentMethodId: bigint,
|
||||
@@ -543,21 +622,10 @@ export class DepositService {
|
||||
const auditMap = await this.attachPlayerAuditLogs(items);
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
paymentMethodId: o.paymentMethodId.toString(),
|
||||
methodType: o.methodType,
|
||||
amount: o.amount.toString(),
|
||||
screenshotUrl: o.screenshotUrl,
|
||||
status: o.status,
|
||||
approvedAmount: o.approvedAmount?.toString() ?? null,
|
||||
rejectReason: o.rejectReason,
|
||||
remark: o.remark,
|
||||
createdAt: o.createdAt,
|
||||
reviewedAt: o.reviewedAt,
|
||||
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
|
||||
auditLogs: (auditMap.get(o.id.toString()) ?? []).map((log) => ({
|
||||
items: items.map((o) =>
|
||||
this.formatPlayerDepositOrder(
|
||||
o,
|
||||
(auditMap.get(o.id.toString()) ?? []).map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
actorType: log.actorType,
|
||||
@@ -568,7 +636,8 @@ export class DepositService {
|
||||
remark: log.remark,
|
||||
createdAt: log.createdAt,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
BIN
apps/player/src/assets/images/cebian.webp
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
apps/player/src/assets/images/generated/auth-panel-bg-v1.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/player/src/assets/images/generated/modal-close-gold-v1.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/player/src/assets/images/generated/modal-gold-frame-v4.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/player/src/assets/images/generated/pc-global-bg-v1.webp
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/player/src/assets/images/generated/pc-sidebar-bg-v1.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
apps/player/src/assets/images/generated/pc-top-nav-bg-v1.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/player/src/assets/images/generated/pc-wallet-banner-v1.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/player/src/assets/images/generated/vs-gold-alpha-v1.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -102,14 +102,19 @@ function close() {
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../assets/images/generated/modal-close-gold-v1.webp') center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.close-x:active {
|
||||
background-image: url('../assets/images/generated/modal-close-gold-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -685,9 +685,11 @@ watch(
|
||||
.drawer {
|
||||
width: 100%;
|
||||
max-height: 88vh;
|
||||
background: #101010;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 22%),
|
||||
rgba(14, 14, 12, 0.98);
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
border-top: 1px solid rgba(255, 226, 156, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -703,7 +705,7 @@ watch(
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
|
||||
background: rgba(12, 12, 11, 0.72);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -769,7 +771,7 @@ watch(
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 8px 12px 0;
|
||||
gap: 6px;
|
||||
background: #101010;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slip-tab {
|
||||
@@ -1087,7 +1089,7 @@ watch(
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
background: rgba(10, 10, 9, 0.84);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { useHoverDropdown } from '../composables/useHoverDropdown';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||
const router = useRouter();
|
||||
const open = ref(false);
|
||||
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
const wallet = computed(() => profileRaw.value?.wallet ?? null);
|
||||
@@ -44,7 +45,12 @@ function toggle() {
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
closeNow();
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
openHover();
|
||||
void refreshProfile();
|
||||
}
|
||||
|
||||
function goRecharge() {
|
||||
@@ -53,7 +59,7 @@ function goRecharge() {
|
||||
}
|
||||
|
||||
function onOutsideClick(e: Event) {
|
||||
if (!root.value?.contains(e.target as Node)) open.value = false;
|
||||
if (!root.value?.contains(e.target as Node)) closeNow();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -68,7 +74,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="cash-chip-wrap">
|
||||
<div
|
||||
ref="root"
|
||||
class="cash-chip-wrap"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="scheduleClose"
|
||||
>
|
||||
<button type="button" class="cash-chip" @click="toggle">
|
||||
<span class="chip-body">
|
||||
<span class="chip-label">{{ t('wallet.cash_balance') }}</span>
|
||||
@@ -99,8 +110,6 @@ onUnmounted(() => {
|
||||
{{ t('recharge.title') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="backdrop" @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,6 +120,16 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
--dropdown-gap: 8px;
|
||||
}
|
||||
|
||||
.cash-chip-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--dropdown-gap);
|
||||
}
|
||||
|
||||
.direct-recharge-btn {
|
||||
@@ -190,7 +209,7 @@ onUnmounted(() => {
|
||||
|
||||
.cash-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: calc(100% + var(--dropdown-gap));
|
||||
right: 0;
|
||||
width: min(260px, 78vw);
|
||||
padding: 12px 14px;
|
||||
|
||||
@@ -101,18 +101,33 @@ function onConfirm() {
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
||||
border-radius: 12px;
|
||||
padding: 22px 18px 16px;
|
||||
box-shadow: 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
max-width: 374px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 38%),
|
||||
rgba(14, 14, 12, 0.96);
|
||||
border: 1px solid rgba(212, 175, 55, 0.34);
|
||||
border-radius: 8px;
|
||||
padding: 31px 28px 25px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.confirm-modal::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(232, 200, 74, 0.65), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--gold, #c8a84e);
|
||||
text-align: center;
|
||||
@@ -120,7 +135,7 @@ function onConfirm() {
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0 0 20px;
|
||||
margin: 0 0 26px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #c8c8c8;
|
||||
@@ -130,17 +145,17 @@ function onConfirm() {
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
min-height: 49px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
transition: opacity 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.confirm-btn:disabled {
|
||||
@@ -149,20 +164,30 @@ function onConfirm() {
|
||||
}
|
||||
|
||||
.confirm-btn.cancel {
|
||||
border: 1px solid #333;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: 0;
|
||||
background: url('../assets/images/generated/pc-betslip-btn-secondary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: #9f9273;
|
||||
}
|
||||
|
||||
.confirm-btn.confirm {
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #d4a017, #e8c84a);
|
||||
color: #1a1a1a;
|
||||
background: url('../assets/images/generated/pc-betslip-btn-primary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: #1b1200;
|
||||
}
|
||||
|
||||
.confirm-btn.confirm.danger {
|
||||
background: linear-gradient(135deg, #c0392b, #e74c3c);
|
||||
color: #fff;
|
||||
.confirm-btn.cancel:not(:disabled):hover,
|
||||
.confirm-btn.confirm:not(:disabled):hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.confirm-btn.cancel:not(:disabled):active {
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-secondary-pressed-v1.webp');
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.confirm-btn.confirm:not(:disabled):active {
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-primary-pressed-v1.webp');
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.confirm-fade-enter-active,
|
||||
|
||||
@@ -19,7 +19,32 @@ const { isDesktop } = useViewport();
|
||||
const auth = useAuthStore();
|
||||
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||
const { unreadCount, messages, listLoaded, refreshUnreadCount, markAllRead, deleteAllMessages } = usePlayerMessages();
|
||||
const { isOpen, toggle, close } = useFloatingMailbox();
|
||||
const { isOpen, toggle, open, close } = useFloatingMailbox();
|
||||
const hovering = ref(false);
|
||||
let hoverTimer: number | undefined = undefined;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (hoverTimer !== undefined) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = undefined;
|
||||
}
|
||||
hovering.value = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
hovering.value = false;
|
||||
hoverTimer = undefined;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openTab(tab: HubTab) {
|
||||
activeTab.value = tab;
|
||||
selectedMessageId.value = null;
|
||||
open();
|
||||
hovering.value = false;
|
||||
}
|
||||
|
||||
|
||||
const markingAll = ref(false);
|
||||
const deletingAll = ref(false);
|
||||
@@ -91,7 +116,42 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-mailbox" :class="{ 'is-betting-desktop': isBettingDesktop }">
|
||||
<div
|
||||
class="floating-mailbox"
|
||||
:class="{ 'is-betting-desktop': isBettingDesktop }"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Hover Menu -->
|
||||
<Transition name="fade-slide-mini">
|
||||
<div v-if="isDesktop && hovering && !isOpen" class="hover-menu">
|
||||
<button
|
||||
v-if="inboxEnabled"
|
||||
type="button"
|
||||
class="menu-item"
|
||||
@click="openTab('messages')"
|
||||
>
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
<span>{{ t('messages.tab_inbox') }}</span>
|
||||
<span v-if="unreadCount > 0" class="menu-badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item"
|
||||
@click="openTab('support')"
|
||||
>
|
||||
<svg class="menu-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z"/>
|
||||
</svg>
|
||||
<span>{{ t('messages.tab_support') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Floating Button -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -121,25 +181,9 @@ onMounted(() => {
|
||||
<Transition name="fade-slide">
|
||||
<div v-if="isOpen" class="popup-panel">
|
||||
<div class="popup-header">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-if="inboxEnabled"
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'messages' }"
|
||||
@click="switchTab('messages')"
|
||||
>
|
||||
{{ t('messages.tab_inbox') }}
|
||||
<span v-if="unreadInList > 0" class="tab-badge">{{ unreadInList }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'support' }"
|
||||
@click="switchTab('support')"
|
||||
>
|
||||
{{ t('messages.tab_support') }}
|
||||
</button>
|
||||
<div class="popup-title">
|
||||
<span>{{ activeTab === 'messages' ? t('messages.tab_inbox') : t('messages.tab_support') }}</span>
|
||||
<span v-if="activeTab === 'messages' && unreadInList > 0" class="tab-badge">{{ unreadInList }}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
@@ -168,6 +212,100 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hover-menu {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
right: 0;
|
||||
background: rgba(22, 22, 22, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
width: 120px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.hover-menu::after,
|
||||
.hover-menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hover-menu::after {
|
||||
bottom: -6px;
|
||||
right: 22px;
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-color: rgba(22, 22, 22, 0.95) transparent transparent transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hover-menu::before {
|
||||
bottom: -7px;
|
||||
right: 21px;
|
||||
border-width: 7px 7px 0 7px;
|
||||
border-color: rgba(212, 175, 55, 0.25) transparent transparent transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--primary-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
margin-left: auto;
|
||||
background: var(--danger, #ff4d4f);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fade-slide-mini-enter-active,
|
||||
.fade-slide-mini-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.fade-slide-mini-enter-from,
|
||||
.fade-slide-mini-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.floating-mailbox {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
@@ -179,47 +317,61 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: url('../assets/images/generated/floating-mailbox-fab-v1.webp') center / cover no-repeat;
|
||||
color: var(--primary-light);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
0 8px 22px rgba(0, 0, 0, 0.42),
|
||||
0 0 16px rgba(212, 175, 55, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s, color 0.2s, filter 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fab-btn:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--primary-light);
|
||||
transform: translateY(-1px);
|
||||
color: #fff1a8;
|
||||
filter: brightness(1.08);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(0, 0, 0, 0.5),
|
||||
0 0 20px rgba(212, 175, 55, 0.18);
|
||||
}
|
||||
|
||||
.fab-active {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.96);
|
||||
color: #fff1a8;
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
background: var(--danger, #ff4d4f);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--bg-card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(255, 69, 58, 0.42);
|
||||
}
|
||||
|
||||
.popup-panel {
|
||||
@@ -249,45 +401,23 @@ onMounted(() => {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
.popup-title {
|
||||
color: var(--primary-light);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
font-weight: 700;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary-light);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
.popup-title .tab-badge {
|
||||
background: var(--primary);
|
||||
color: #141414;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tab-btn.active .tab-badge {
|
||||
background: var(--primary);
|
||||
color: #141414;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MatchBetCard from './MatchBetCard.vue';
|
||||
import saishiImg from '../assets/images/saishi.webp';
|
||||
import cardBg from '../assets/images/card-bg.webp';
|
||||
import saishiImg from '../assets/images/generated/saishi-gold-trophy-v1.webp';
|
||||
import cardBg from '../assets/images/generated/pc-match-list-card-bg-v1.webp';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LocaleFlag from './LocaleFlag.vue';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import { useHoverDropdown } from '../composables/useHoverDropdown';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -14,7 +15,7 @@ withDefaults(
|
||||
const { locale } = useI18n();
|
||||
const { locales, setLocale } = useAppLocale();
|
||||
|
||||
const open = ref(false);
|
||||
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
const currentLabel = computed(
|
||||
@@ -22,7 +23,7 @@ const currentLabel = computed(
|
||||
);
|
||||
|
||||
async function pick(code: string) {
|
||||
open.value = false;
|
||||
closeNow();
|
||||
await setLocale(code);
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ function toggle() {
|
||||
}
|
||||
|
||||
function onOutsideClick(e: Event) {
|
||||
if (!root.value?.contains(e.target as Node)) open.value = false;
|
||||
if (!root.value?.contains(e.target as Node)) closeNow();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -45,7 +46,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="locale-switch" :class="{ compact, open }">
|
||||
<div
|
||||
ref="root"
|
||||
class="locale-switch"
|
||||
:class="{ compact, open }"
|
||||
@mouseenter="openHover"
|
||||
@mouseleave="scheduleClose"
|
||||
>
|
||||
<button type="button" class="locale-trigger" :aria-expanded="open" aria-haspopup="listbox" @click.stop="toggle">
|
||||
<LocaleFlag :locale="locale" :size="compact ? 16 : 18" />
|
||||
</button> <ul v-show="open" class="locale-menu" role="listbox" :aria-label="compact ? 'Language' : undefined">
|
||||
@@ -71,6 +78,16 @@ onUnmounted(() => {
|
||||
z-index: 120;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
--dropdown-gap: 4px;
|
||||
}
|
||||
|
||||
.locale-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--dropdown-gap);
|
||||
}
|
||||
|
||||
.locale-trigger {
|
||||
@@ -99,7 +116,7 @@ onUnmounted(() => {
|
||||
|
||||
.locale-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
top: calc(100% + var(--dropdown-gap));
|
||||
right: 0;
|
||||
z-index: 130;
|
||||
min-width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
@@ -28,8 +28,7 @@ function continueBrowsing() {
|
||||
<div v-if="auth.loginPromptVisible" class="login-overlay" @click.self="continueBrowsing">
|
||||
<div class="login-modal">
|
||||
<button type="button" class="close-btn" :aria-label="t('auth.continue_browsing')" @click="continueBrowsing">
|
||||
✕
|
||||
</button>
|
||||
×</button>
|
||||
<h2 class="login-title">{{ t('auth.login_required') }}</h2>
|
||||
<p class="login-hint">{{ t('auth.login_hint') }}</p>
|
||||
|
||||
@@ -64,10 +63,13 @@ function continueBrowsing() {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 360px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
||||
border-radius: 12px;
|
||||
padding: 28px 24px 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 36%),
|
||||
rgba(14, 14, 12, 0.96);
|
||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 36px 30px 30px;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, getCurrentInstance } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useDesktopBetPopover } from '../composables/useDesktopBetPopover';
|
||||
import { resolveSelectionLabel } from '../utils/selectionLabel';
|
||||
import MarketSelectionsPanel from './match-detail/MarketSelectionsPanel.vue';
|
||||
import vsImg from '../assets/images/generated/vs-gold-alpha-v1.webp';
|
||||
import matchListCardBg from '../assets/images/generated/pc-match-list-card-bg-v1.webp';
|
||||
|
||||
const matchCardBg = `url(${matchListCardBg})`;
|
||||
|
||||
const props = defineProps<{
|
||||
match: {
|
||||
@@ -81,8 +84,45 @@ const liveScoreText = computed(() => {
|
||||
|
||||
const router = useRouter();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { openAt } = useDesktopBetPopover();
|
||||
const { openAt, visible: popoverVisible, pendingItem, cancelClose, scheduleClose, isActiveForCard } = useDesktopBetPopover();
|
||||
|
||||
const cardRootId = `bet-card-${props.match.id}-${getCurrentInstance()?.uid ?? 0}`;
|
||||
|
||||
const isCardEngaged = computed(() => isActiveForCard(cardRootId));
|
||||
|
||||
function getHoveredSide() {
|
||||
if (!popoverVisible.value || !pendingItem.value) return '';
|
||||
if (!isActiveForCard(cardRootId)) return '';
|
||||
if (String(pendingItem.value.matchId) !== String(props.match.id)) return '';
|
||||
|
||||
const selId = pendingItem.value.selectionId;
|
||||
// Scan all markets to find the selection and get its code
|
||||
for (const market of props.match.markets ?? []) {
|
||||
const sel = market.selections?.find((s: any) => s.id === selId);
|
||||
if (!sel) continue;
|
||||
const code = (sel.selectionCode || '').toUpperCase();
|
||||
if (code === 'HOME') return 'home';
|
||||
if (code === 'AWAY') return 'away';
|
||||
if (code === 'DRAW') return 'draw';
|
||||
// Over/Under: over = home side, under = away side (for visual cue)
|
||||
if (code === 'OVER') return 'home';
|
||||
if (code === 'UNDER') return 'away';
|
||||
// Odd/Even: highlight both
|
||||
if (code === 'ODD' || code === 'EVEN') return 'draw';
|
||||
// Fallback: use selection index
|
||||
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
|
||||
if (selIndex === -1) continue;
|
||||
if (market.marketType === 'FT_1X2') {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'draw';
|
||||
if (selIndex === 2) return 'away';
|
||||
} else {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'away';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const activeMarketType = ref<string>('');
|
||||
|
||||
@@ -120,10 +160,6 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
|
||||
function handleOddsClick(match: any, market: any, selId: string, event?: MouseEvent) {
|
||||
if (isMarketLocked(match, market)) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt(router.currentRoute.value.fullPath);
|
||||
return;
|
||||
}
|
||||
const sel = market.selections?.find((s: any) => s.id === selId);
|
||||
if (!sel || !event) return;
|
||||
|
||||
@@ -166,14 +202,21 @@ function goMatchDetail() {
|
||||
<template>
|
||||
<article
|
||||
class="match-card"
|
||||
:class="{ 'match-card--phase': phase !== 'open', 'no-quick-bet': noQuickBet }"
|
||||
:data-bet-card-root="cardRootId"
|
||||
:class="{
|
||||
'match-card--phase': phase !== 'open',
|
||||
'no-quick-bet': noQuickBet,
|
||||
'match-card--engaged': isCardEngaged,
|
||||
}"
|
||||
@click="emit('bet', match.id)"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<span v-if="phase === 'open'" class="status-tag status-tag--open">{{ t('bet.status_open') }}</span>
|
||||
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
||||
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
||||
|
||||
<div class="teams-row">
|
||||
<div class="teams-row" :class="getHoveredSide()">
|
||||
<div class="team">
|
||||
<span class="team-name">{{ match.homeTeamName }}</span>
|
||||
<img
|
||||
@@ -190,7 +233,9 @@ function goMatchDetail() {
|
||||
<div class="center-col">
|
||||
<span class="kickoff">{{ kickoffText }}</span>
|
||||
<span v-if="phase !== 'open' && liveScoreText" class="live-score">{{ liveScoreText }}</span>
|
||||
<span v-else class="vs">VS</span>
|
||||
<span v-else class="vs">
|
||||
<img :src="vsImg" alt="VS" class="vs-img" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="team">
|
||||
@@ -255,9 +300,9 @@ function goMatchDetail() {
|
||||
<style scoped>
|
||||
.match-card {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||
border-radius: 6px;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 10px 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -273,7 +318,7 @@ function goMatchDetail() {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.2));
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -373,13 +418,21 @@ function goMatchDetail() {
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 0.08em;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||
transition: font-size 0.25s ease;
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.3s ease;
|
||||
}
|
||||
|
||||
.vs-img {
|
||||
width: 46px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@@ -453,36 +506,36 @@ function goMatchDetail() {
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.match-card:not(.no-quick-bet):hover {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) {
|
||||
border-color: var(--border-gold);
|
||||
box-shadow: var(--shadow-gold);
|
||||
}
|
||||
|
||||
/* 整行保持原位,间距自然 */
|
||||
.match-card:not(.no-quick-bet):hover .teams-row {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .teams-row {
|
||||
transform: translateY(0);
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
/* .team 取消自身 translateY */
|
||||
.match-card:not(.no-quick-bet):hover .team {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team {
|
||||
transform: translateY(0);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* 旗帜缩小到 40×28 */
|
||||
.match-card:not(.no-quick-bet):hover .team-flag {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.match-card:not(.no-quick-bet):hover .team-flag.flag-logo {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag.flag-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* 队名缩小并禁止换行 */
|
||||
.match-card:not(.no-quick-bet):hover .team-name {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-name {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -491,7 +544,7 @@ function goMatchDetail() {
|
||||
}
|
||||
|
||||
/* 隐藏开赛时间 */
|
||||
.match-card:not(.no-quick-bet):hover .kickoff {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .kickoff {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
@@ -500,19 +553,19 @@ function goMatchDetail() {
|
||||
}
|
||||
|
||||
/* VS / 比分 */
|
||||
.match-card:not(.no-quick-bet):hover .vs,
|
||||
.match-card:not(.no-quick-bet):hover .live-score {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .vs,
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .live-score {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 下注按钮淡出(保留布局空间避免高度抖动) */
|
||||
.match-card:not(.no-quick-bet):hover .bet-btn {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .bet-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 快速下注面板淡入 */
|
||||
.match-card:not(.no-quick-bet):hover .card-quick-bet {
|
||||
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .card-quick-bet {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
@@ -593,4 +646,35 @@ function goMatchDetail() {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- VS Selection Highlight: scale selected team, no VS animation --- */
|
||||
.teams-row .team {
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
.teams-row.home .team:first-child {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
z-index: 2;
|
||||
}
|
||||
.teams-row.home .team:last-child {
|
||||
opacity: 0.35 !important;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.teams-row.away .team:last-child {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
z-index: 2;
|
||||
}
|
||||
.teams-row.away .team:first-child {
|
||||
opacity: 0.35 !important;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.teams-row.draw .team {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.15) saturate(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
||||
import { stripHtml } from '../utils/html';
|
||||
import emptyInboxImg from '../assets/images/generated/empty-inbox-envelope-v1.webp';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -116,6 +117,7 @@ onActivated(tryLoad);
|
||||
</div>
|
||||
|
||||
<div v-else-if="!messages.length" class="empty">
|
||||
<img :src="emptyInboxImg" alt="" class="empty-img" />
|
||||
<p>{{ t('messages.empty') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -194,6 +196,17 @@ onActivated(tryLoad);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: min(320px, 86vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
opacity: 0.9;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
|
||||
.login-link {
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 8px;
|
||||
|
||||
284
apps/player/src/components/MouseRippleGrid.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MouseRippleGrid — interactive grid that only appears near the cursor.
|
||||
* Mouse movement creates expanding ripple waves that distort the grid.
|
||||
*
|
||||
* Performance notes:
|
||||
* - Skips points outside the max effective radius of all ripples + mouse
|
||||
* - Caps devicePixelRatio at 2
|
||||
* - Respects prefers-reduced-motion (renders nothing)
|
||||
* - pointer-events:none so form interactions are never blocked
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let parent: HTMLElement | null = null;
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
let reducedMotion = false;
|
||||
|
||||
/* ---- Mouse state ---- */
|
||||
let mouseX = -9999;
|
||||
let mouseY = -9999;
|
||||
let lastRippleX = -9999;
|
||||
let lastRippleY = -9999;
|
||||
let mouseInside = false;
|
||||
let mouseFade = 0; // smoothed 0→1 for graceful fade
|
||||
|
||||
/* ---- Ripple data ---- */
|
||||
interface Ripple {
|
||||
x: number;
|
||||
y: number;
|
||||
born: number;
|
||||
strength: number;
|
||||
}
|
||||
const ripples: Ripple[] = [];
|
||||
|
||||
/* ---- Tuning constants ---- */
|
||||
const GRID = 34; // grid spacing px
|
||||
const VISIBLE_R = 210; // mouse visibility radius
|
||||
const RIPPLE_SPEED = 140; // px/s propagation
|
||||
const RIPPLE_LIFE = 2000; // ms
|
||||
const RIPPLE_SKIP_R = 380; // skip points farther than this from any ripple
|
||||
const SIGMA = 42; // wave packet width
|
||||
const MAX_DISPLACE = 14; // cap displacement px
|
||||
|
||||
function resize() {
|
||||
const c = canvasRef.value;
|
||||
if (!c || !c.parentElement) return;
|
||||
parent = c.parentElement;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
c.width = Math.round(width * dpr);
|
||||
c.height = Math.round(height * dpr);
|
||||
c.style.width = width + 'px';
|
||||
c.style.height = height + 'px';
|
||||
ctx = c.getContext('2d');
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
function handleMove(e: MouseEvent) {
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
mouseInside = true;
|
||||
|
||||
const dx = mouseX - lastRippleX;
|
||||
const dy = mouseY - lastRippleY;
|
||||
if (dx * dx + dy * dy > 36) {
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
ripples.push({
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
born: performance.now(),
|
||||
strength: Math.min(dist * 0.12, 5),
|
||||
});
|
||||
lastRippleX = mouseX;
|
||||
lastRippleY = mouseY;
|
||||
if (ripples.length > 30) ripples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
mouseInside = true;
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
mouseInside = false;
|
||||
mouseX = -9999;
|
||||
mouseY = -9999;
|
||||
}
|
||||
|
||||
function frame() {
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!ctx) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
/* Smooth fade in/out when mouse enters/leaves */
|
||||
const target = mouseInside ? 1 : 0;
|
||||
mouseFade += (target - mouseFade) * 0.05;
|
||||
|
||||
/* Purge expired ripples */
|
||||
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||
if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1);
|
||||
}
|
||||
|
||||
/* Nothing to draw — clear and bail */
|
||||
if (mouseFade < 0.004 && ripples.length === 0) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const cols = Math.ceil(width / GRID) + 2;
|
||||
const rows = Math.ceil(height / GRID) + 2;
|
||||
|
||||
/* Precompute active ripple data for this frame */
|
||||
type ARipple = { x: number; y: number; age: number; strength: number };
|
||||
const active: ARipple[] = [];
|
||||
for (const r of ripples) {
|
||||
const age = (now - r.born) / RIPPLE_LIFE;
|
||||
if (age < 1) active.push({ x: r.x, y: r.y, age, strength: r.strength });
|
||||
}
|
||||
|
||||
/* ---- Pass 1: compute displaced positions + opacities ---- */
|
||||
type Cell = { x: number; y: number; o: number };
|
||||
const grid: Cell[][] = new Array(rows);
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
grid[row] = new Array(cols);
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const bx = (col - 0.5) * GRID;
|
||||
const by = (row - 0.5) * GRID;
|
||||
|
||||
/* Base opacity from mouse proximity */
|
||||
let opacity = 0;
|
||||
if (mouseFade > 0.004) {
|
||||
const mdx = bx - mouseX;
|
||||
const mdy = by - mouseY;
|
||||
const mdist = Math.sqrt(mdx * mdx + mdy * mdy);
|
||||
if (mdist < VISIBLE_R) {
|
||||
const f = 1 - mdist / VISIBLE_R;
|
||||
opacity = f * f * 0.28 * mouseFade;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple displacement */
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
let rippleO = 0;
|
||||
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
const r = active[i];
|
||||
const rdx = bx - r.x;
|
||||
const rdy = by - r.y;
|
||||
const rdist = Math.sqrt(rdx * rdx + rdy * rdy);
|
||||
if (rdist > RIPPLE_SKIP_R) continue;
|
||||
|
||||
const wavefront = r.age * RIPPLE_SPEED * (RIPPLE_LIFE / 1000);
|
||||
const distFromFront = rdist - wavefront;
|
||||
const env = Math.exp(-(distFromFront * distFromFront) / (2 * SIGMA * SIGMA));
|
||||
const osc = Math.cos(distFromFront * 0.11);
|
||||
const decay = (1 - r.age) * Math.exp(-rdist / 190);
|
||||
const amp = r.strength * decay * env * osc * 6;
|
||||
|
||||
if (rdist > 0.5) {
|
||||
dx += (rdx / rdist) * amp;
|
||||
dy += (rdy / rdist) * amp;
|
||||
}
|
||||
rippleO = Math.max(rippleO, decay * env * 0.45);
|
||||
}
|
||||
|
||||
/* Cap displacement */
|
||||
const dLen = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dLen > MAX_DISPLACE) {
|
||||
dx = (dx / dLen) * MAX_DISPLACE;
|
||||
dy = (dy / dLen) * MAX_DISPLACE;
|
||||
}
|
||||
|
||||
opacity = Math.max(opacity, rippleO * mouseFade);
|
||||
grid[row][col] = { x: bx + dx, y: by + dy, o: opacity };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Pass 2: draw grid lines ---- */
|
||||
ctx.lineWidth = 0.6;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const p = grid[row][col];
|
||||
if (p.o < 0.008) continue;
|
||||
|
||||
/* Line to right neighbour */
|
||||
if (col < cols - 1) {
|
||||
const n = grid[row][col + 1];
|
||||
if (n.o > 0.008) {
|
||||
const lo = Math.min(p.o, n.o) * 0.55;
|
||||
ctx.strokeStyle = `rgba(212,175,55,${lo})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(n.x, n.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* Line to bottom neighbour */
|
||||
if (row < rows - 1) {
|
||||
const n = grid[row + 1][col];
|
||||
if (n.o > 0.008) {
|
||||
const lo = Math.min(p.o, n.o) * 0.55;
|
||||
ctx.strokeStyle = `rgba(212,175,55,${lo})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(n.x, n.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Pass 3: draw intersection dots ---- */
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const p = grid[row][col];
|
||||
if (p.o < 0.008) continue;
|
||||
ctx.fillStyle = `rgba(240,216,117,${p.o})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
resize();
|
||||
|
||||
const c = canvasRef.value;
|
||||
if (!c || !c.parentElement) return;
|
||||
parent = c.parentElement;
|
||||
|
||||
parent.addEventListener('mousemove', handleMove);
|
||||
parent.addEventListener('mouseenter', handleEnter);
|
||||
parent.addEventListener('mouseleave', handleLeave);
|
||||
|
||||
resizeObs = new ResizeObserver(resize);
|
||||
resizeObs.observe(parent);
|
||||
|
||||
if (!reducedMotion) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMove);
|
||||
parent.removeEventListener('mouseenter', handleEnter);
|
||||
parent.removeEventListener('mouseleave', handleLeave);
|
||||
}
|
||||
resizeObs?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasRef" class="ripple-grid" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ripple-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -36,12 +36,12 @@ function confirm() {
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="close">
|
||||
<div class="modal" role="dialog" aria-modal="true" :aria-label="t('profile.avatar')">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
<h3 class="title">{{ t('profile.avatar') }}</h3>
|
||||
<PlayerAvatarPicker v-model="draft" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-cancel" @click="close">{{ t('bet.cancel') }}</button>
|
||||
<button type="button" class="btn-confirm btn-gold-outline" @click="confirm">
|
||||
<button type="button" class="btn-confirm" @click="confirm">
|
||||
{{ t('profile.avatar_confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -69,28 +69,34 @@ function confirm() {
|
||||
max-width: 360px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 14px 14px;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 36%),
|
||||
rgba(14, 14, 12, 0.96);
|
||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 26px 24px 22px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../assets/images/generated/modal-close-gold-v1.webp') center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-x:active {
|
||||
background-image: url('../assets/images/generated/modal-close-gold-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 28px 12px 0;
|
||||
font-size: 15px;
|
||||
@@ -108,18 +114,31 @@ function confirm() {
|
||||
.btn-confirm {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
background-color: transparent;
|
||||
background-position: center;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border: 1px solid var(--border);
|
||||
background: #0a0a0a;
|
||||
color: var(--text-muted);
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-secondary-v1.webp');
|
||||
color: #f3e7c5;
|
||||
}
|
||||
|
||||
.btn-cancel:active {
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-secondary-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
border: none;
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-primary-v1.webp');
|
||||
color: #1d1300;
|
||||
}
|
||||
|
||||
.btn-confirm:active {
|
||||
background-image: url('../assets/images/generated/pc-betslip-btn-primary-pressed-v1.webp');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,12 +5,13 @@ import { useI18n } from 'vue-i18n';
|
||||
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { useHoverDropdown } from '../composables/useHoverDropdown';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { avatarUrl } = usePlayerProfile();
|
||||
const open = ref(false);
|
||||
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
const displayAvatarUrl = computed(() => {
|
||||
@@ -24,11 +25,11 @@ function toggle() {
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
closeNow();
|
||||
}
|
||||
|
||||
function onOutsideClick(e: Event) {
|
||||
if (!root.value?.contains(e.target as Node)) open.value = false;
|
||||
if (!root.value?.contains(e.target as Node)) closeNow();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -58,7 +59,12 @@ function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="avatar-wrap">
|
||||
<div
|
||||
ref="root"
|
||||
class="avatar-wrap"
|
||||
@mouseenter="openHover"
|
||||
@mouseleave="scheduleClose"
|
||||
>
|
||||
<button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle">
|
||||
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
|
||||
</button>
|
||||
@@ -69,8 +75,6 @@ function logout() {
|
||||
<button type="button" class="menu-item" @click="goEdit">{{ t('profile.edit') }}</button>
|
||||
<button type="button" class="menu-item danger" @click="logout">{{ t('auth.logout') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="backdrop" @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,6 +82,16 @@ function logout() {
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
z-index: 130;
|
||||
--dropdown-gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--dropdown-gap);
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
@@ -111,7 +125,7 @@ function logout() {
|
||||
|
||||
.avatar-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: calc(100% + var(--dropdown-gap));
|
||||
right: 0;
|
||||
min-width: 168px;
|
||||
padding: 8px 0;
|
||||
|
||||
@@ -1181,7 +1181,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: rgba(16, 16, 16, 0.95);
|
||||
background:
|
||||
linear-gradient(rgba(14, 12, 9, 0.58), rgba(14, 12, 9, 0.68)),
|
||||
url('../../assets/images/generated/pc-betslip-sidebar-bg-v1.webp') no-repeat center / cover;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@@ -1233,14 +1235,22 @@ onUnmounted(() => {
|
||||
flex: 0 0 20%;
|
||||
min-width: 0;
|
||||
padding: 10px 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/pc-betslip-btn-secondary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: var(--primary-light);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-slip-btn:hover {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
color: #fff0a8;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.clear-slip-btn.foot-clear:active {
|
||||
background-image: url('../../assets/images/generated/pc-betslip-btn-secondary-pressed-v1.webp');
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
@@ -1806,17 +1816,26 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #F0D875 0%, #D4AF37 100%);
|
||||
color: #3D2800;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/pc-betslip-btn-primary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: #1b1200;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity 0.2s, filter 0.15s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn:not(:disabled):hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.submit-btn:not(:disabled):active {
|
||||
background-image: url('../../assets/images/generated/pc-betslip-btn-primary-pressed-v1.webp');
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
253
apps/player/src/components/desktop/DesktopAuthLayout.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PC auth shell — admin-style card: large logo left, form slot right.
|
||||
*/
|
||||
import MouseRippleGrid from '../MouseRippleGrid.vue';
|
||||
import LocaleSwitcher from '../LocaleSwitcher.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Wider card for register / forgot-password forms */
|
||||
wide?: boolean;
|
||||
}>(),
|
||||
{ wide: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-auth-page">
|
||||
<div class="ambient-glow ambient-glow--1" aria-hidden="true"></div>
|
||||
<div class="ambient-glow ambient-glow--2" aria-hidden="true"></div>
|
||||
<MouseRippleGrid />
|
||||
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card" :class="{ 'auth-card--wide': wide }">
|
||||
<div class="form-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<div class="form-body">
|
||||
<aside class="form-brand">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo-large" />
|
||||
</aside>
|
||||
<div class="form-fields">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-auth-page {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.desktop-auth-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212, 175, 55, 0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
opacity: 0.12;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ambient-glow--1 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
top: 10%;
|
||||
left: 12%;
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.5) 0%, transparent 70%);
|
||||
animation: ambient-drift-1 12s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.ambient-glow--2 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
bottom: 12%;
|
||||
right: 12%;
|
||||
background: radial-gradient(circle, rgba(240, 216, 117, 0.35) 0%, transparent 70%);
|
||||
animation: ambient-drift-2 16s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ambient-drift-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(30px, -20px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ambient-drift-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-25px, 15px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: 24px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
background:
|
||||
linear-gradient(rgba(8, 8, 7, 0.18), rgba(8, 8, 7, 0.24)),
|
||||
url('../../assets/images/generated/auth-panel-bg-v1.webp') center / 100% 100% no-repeat;
|
||||
backdrop-filter: var(--blur-lg);
|
||||
-webkit-backdrop-filter: var(--blur-lg);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
filter 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.auth-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: url('../../assets/images/generated/auth-panel-bg-v1.webp') center / 100% 100% no-repeat;
|
||||
filter: brightness(1.28) saturate(1.16);
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.auth-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 8%, rgba(255, 226, 156, 0.08) 50%, transparent 92%),
|
||||
radial-gradient(circle at 72% 48%, rgba(212, 175, 55, 0.1), transparent 34%);
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.auth-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.04) saturate(1.05);
|
||||
}
|
||||
|
||||
.auth-card:focus-within::before {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.auth-card:focus-within::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.auth-card--wide {
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
.form-lang {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 24px 0;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) 1fr;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6) var(--space-5);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-right: 1px solid rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.logo-large {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 24px rgba(212, 175, 55, 0.25));
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 22px 24px 24px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.auth-wrap {
|
||||
align-items: flex-start;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.auth-card,
|
||||
.auth-card--wide {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-brand {
|
||||
padding: 20px 24px 12px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.logo-large {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
padding: 18px 24px 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-glow--1,
|
||||
.ambient-glow--2 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
392
apps/player/src/components/desktop/DesktopBetDetailModal.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<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>> = {
|
||||
'\u4e3b\u80dc': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
|
||||
'\u5ba2\u80dc': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
|
||||
'\u548c\u5c40': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
|
||||
'\u4e3b': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
|
||||
'\u5ba2': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
|
||||
'\u5927': { 'en-US': 'Over', 'ms-MY': 'Atas' },
|
||||
'\u5c0f': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
|
||||
'\u5355': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
|
||||
'\u53cc': { 'en-US': 'Even', 'ms-MY': 'Genap' },
|
||||
'\u51a0\u519b': { '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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 22px 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 36%),
|
||||
rgba(14, 14, 12, 0.96);
|
||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.58);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0 12px;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.16);
|
||||
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 rgba(212, 175, 55, 0.12); }
|
||||
|
||||
.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 0 2px; }
|
||||
|
||||
.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>
|
||||
@@ -47,7 +47,9 @@ watch(
|
||||
width: var(--desktop-right-betslip-w);
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
background:
|
||||
linear-gradient(rgba(14, 12, 9, 0.58), rgba(14, 12, 9, 0.68)),
|
||||
url('../../assets/images/generated/pc-betslip-sidebar-bg-v1.webp') no-repeat center / cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
@@ -10,9 +10,10 @@ import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||
import ConfirmDialog from '../ConfirmDialog.vue';
|
||||
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
|
||||
import { useAppToast } from '../../composables/useAppToast';
|
||||
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { visible, anchorX, anchorY, pendingItem, close } = useDesktopBetPopover();
|
||||
const { visible, anchorX, anchorY, pendingItem, placement, close, cancelClose, scheduleClose } = useDesktopBetPopover();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { refreshProfile } = usePlayerProfile();
|
||||
@@ -25,6 +26,20 @@ const error = ref('');
|
||||
const showPlaceConfirm = ref(false);
|
||||
const placeConfirmMessage = ref('');
|
||||
const MIN_STAKE = 5;
|
||||
const showSuccess = ref(false);
|
||||
const confirmingItem = ref<any>(null);
|
||||
|
||||
watch(showPlaceConfirm, (val) => {
|
||||
if (!val) {
|
||||
confirmingItem.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onPopoverMouseLeave() {
|
||||
if (showPlaceConfirm.value) return;
|
||||
scheduleClose(200);
|
||||
}
|
||||
|
||||
|
||||
let outsideClickTimer = 0;
|
||||
|
||||
@@ -95,6 +110,7 @@ function validatePlaceNow(): boolean {
|
||||
function onPlaceNowClick() {
|
||||
if (!validatePlaceNow()) return;
|
||||
const item = pendingItem.value!;
|
||||
confirmingItem.value = item;
|
||||
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
||||
mode: 'single',
|
||||
items: [item],
|
||||
@@ -107,7 +123,7 @@ function onPlaceNowClick() {
|
||||
showPlaceConfirm.value = true;
|
||||
}
|
||||
|
||||
async function executePlaceNow(item = pendingItem.value) {
|
||||
async function executePlaceNow(item = confirmingItem.value || pendingItem.value) {
|
||||
if (!item) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -122,7 +138,7 @@ async function executePlaceNow(item = pendingItem.value) {
|
||||
await refreshProfile();
|
||||
showPlaceConfirm.value = false;
|
||||
close();
|
||||
showToast(t('bet.place_success'));
|
||||
showSuccess.value = true;
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
@@ -134,7 +150,7 @@ async function executePlaceNow(item = pendingItem.value) {
|
||||
}
|
||||
|
||||
async function confirmPlaceNow() {
|
||||
const item = pendingItem.value;
|
||||
const item = confirmingItem.value;
|
||||
if (!item) return;
|
||||
showPlaceConfirm.value = false;
|
||||
await executePlaceNow(item);
|
||||
@@ -184,11 +200,13 @@ function addToParlayList() {
|
||||
v-if="visible && pendingItem"
|
||||
ref="popRef"
|
||||
class="bet-popover"
|
||||
:class="`placement-${placement}`"
|
||||
tabindex="-1"
|
||||
:style="{ left: `${anchorX}px`, top: `${anchorY}px` }"
|
||||
@click.stop
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="onPopoverMouseLeave"
|
||||
>
|
||||
<button type="button" class="pop-close" aria-label="Close" @click="close">✕</button>
|
||||
|
||||
<div class="pop-match">{{ pendingItem.matchName }}</div>
|
||||
<div class="pop-market">{{ pendingItem.marketName }}</div>
|
||||
@@ -233,104 +251,146 @@ function addToParlayList() {
|
||||
@confirm="confirmPlaceNow"
|
||||
/>
|
||||
|
||||
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
padding: 12px 28px 12px 12px;
|
||||
width: 190px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(18, 18, 18, 0.98);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
background: rgba(22, 22, 22, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.pop-close {
|
||||
.bet-popover::after,
|
||||
.bet-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pop-close:hover {
|
||||
color: #fff;
|
||||
.bet-popover.placement-bottom::after {
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-color: transparent transparent rgba(22, 22, 22, 0.9) transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bet-popover.placement-bottom::before {
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 7px 7px 7px;
|
||||
border-color: transparent transparent rgba(212, 175, 55, 0.2) transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bet-popover.placement-top::after {
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-color: rgba(22, 22, 22, 0.9) transparent transparent transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bet-popover.placement-top::before {
|
||||
bottom: -7px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 7px 7px 0 7px;
|
||||
border-color: rgba(212, 175, 55, 0.2) transparent transparent transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pop-match {
|
||||
font-size: 11px;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pop-market {
|
||||
font-size: 10px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pop-pick-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
border: 1px solid rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.pick {
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.odds {
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d0d0d;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #080808;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.stake-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.est-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.est-val {
|
||||
@@ -339,20 +399,20 @@ function addToParlayList() {
|
||||
}
|
||||
|
||||
.pop-error {
|
||||
font-size: 10px;
|
||||
font-size: 8px;
|
||||
color: var(--danger);
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
|
||||
.pop-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pop-actions-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pop-actions-row .btn-outline {
|
||||
@@ -363,18 +423,19 @@ function addToParlayList() {
|
||||
|
||||
.btn-primary-gold {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
|
||||
color: #3d2800;
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary-gold:hover:not(:disabled) {
|
||||
opacity: 0.92;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary-gold:disabled {
|
||||
@@ -384,17 +445,18 @@ function addToParlayList() {
|
||||
|
||||
.btn-outline {
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
background: rgba(212, 175, 55, 0.03);
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { OutrightEvent, OutrightSelection } from '../outright/OutrightEventSection.vue';
|
||||
import saishiImg from '../../assets/images/saishi.webp';
|
||||
import saishiImg from '../../assets/images/generated/saishi-gold-trophy-v1.webp';
|
||||
import outrightListCardBg from '../../assets/images/generated/pc-match-list-card-bg-v1.webp';
|
||||
|
||||
const outrightCardBg = `url(${outrightListCardBg})`;
|
||||
|
||||
const props = defineProps<{
|
||||
event: OutrightEvent;
|
||||
@@ -107,9 +110,9 @@ const topSelections = computed(() => {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: v-bind(outrightCardBg) center / 100% 100% no-repeat;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -270,18 +273,17 @@ const topSelections = computed(() => {
|
||||
flex: 1; /* equal share of available width */
|
||||
gap: 1px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/odds-button-normal-v1.webp') center / 100% 100% no-repeat;
|
||||
cursor: pointer;
|
||||
min-height: 32px;
|
||||
transition: all 0.2s ease;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.odds-btn:hover {
|
||||
background: var(--border-gold-soft);
|
||||
border-color: var(--border-gold);
|
||||
box-shadow: 0 0 8px rgba(212, 175, 55, 0.25);
|
||||
background-image: url('../../assets/images/generated/odds-button-selected-v1.webp');
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.selection-name {
|
||||
|
||||
@@ -39,7 +39,7 @@ const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
|
||||
p === '/bets' ||
|
||||
p.startsWith('/bets/') ||
|
||||
p.startsWith('/wallet') ||
|
||||
p === '/profile/cashbacks'
|
||||
p.startsWith('/profile')
|
||||
) {
|
||||
return 'account';
|
||||
}
|
||||
|
||||
293
apps/player/src/components/desktop/DesktopTxDetailModal.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 22px 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 36%),
|
||||
rgba(14, 14, 12, 0.96);
|
||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.58);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0 12px;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.16);
|
||||
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>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { groupCorrectScoreSelections, type CsSelection } from '../../utils/correctScoreLayout';
|
||||
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
marketType: string;
|
||||
@@ -24,6 +26,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { cancelClose } = useDesktopBetPopover();
|
||||
|
||||
const columns = computed(() =>
|
||||
groupCorrectScoreSelections(
|
||||
@@ -48,7 +51,11 @@ function formatOdds(odds: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs-panel" :class="{ 'cs-panel--locked': locked, 'cs-panel--dense': dense }">
|
||||
<div
|
||||
class="cs-panel"
|
||||
:class="{ 'cs-panel--locked': locked, 'cs-panel--dense': dense }"
|
||||
@mouseenter="cancelClose"
|
||||
>
|
||||
<div class="cols-head">
|
||||
<span>{{ t('bet.col_home') }}</span>
|
||||
<span>{{ t('bet.col_draw') }}</span>
|
||||
@@ -65,6 +72,7 @@ function formatOdds(odds: string) {
|
||||
:class="{ selected: isSelected(sel.id), 'score-card--locked': locked }"
|
||||
:data-bet-selection-id="sel.id"
|
||||
:disabled="locked"
|
||||
@mouseenter="onPick(sel, $event)"
|
||||
@click="onPick(sel, $event)"
|
||||
>
|
||||
<span class="score-line">{{ sel.scoreDisplay }}</span>
|
||||
@@ -121,16 +129,14 @@ function formatOdds(odds: string) {
|
||||
gap: 3px;
|
||||
min-height: 42px;
|
||||
padding: 6px 3px;
|
||||
background: #161616;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 3px;
|
||||
background: url('../../assets/images/generated/odds-button-normal-v1.webp') center / 100% 100% no-repeat;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: #2d281a;
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
|
||||
background-image: url('../../assets/images/generated/odds-button-selected-v1.webp');
|
||||
}
|
||||
|
||||
.score-card.selected .score-line {
|
||||
@@ -142,8 +148,7 @@ function formatOdds(odds: string) {
|
||||
}
|
||||
|
||||
.score-card--locked {
|
||||
background: #111;
|
||||
border-color: #333;
|
||||
background-image: url('../../assets/images/generated/odds-button-disabled-v1.webp');
|
||||
}
|
||||
|
||||
.score-card--locked .score-line,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
selections: {
|
||||
@@ -23,6 +25,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ pick: [id: string, event?: MouseEvent] }>();
|
||||
const { t } = useI18n();
|
||||
const { cancelClose } = useDesktopBetPopover();
|
||||
|
||||
function label(sel: (typeof props.selections)[number]) {
|
||||
if (sel.selectionDisplayName?.trim()) return sel.selectionDisplayName;
|
||||
@@ -53,7 +56,7 @@ const panelStyle = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" :class="{ compact, horizontal, desktopCard, locked }">
|
||||
<div class="wrap" :class="{ compact, horizontal, desktopCard, locked }" @mouseenter="cancelClose">
|
||||
<div
|
||||
class="panel"
|
||||
:class="{ horizontal, 'desktop-card': desktopCard }"
|
||||
@@ -67,6 +70,7 @@ const panelStyle = computed(() =>
|
||||
:class="{ selected: isSelected(sel.id), 'odds-btn--locked': locked }"
|
||||
:data-bet-selection-id="sel.id"
|
||||
:disabled="locked"
|
||||
@mouseenter="onPick(sel.id, $event)"
|
||||
@click="onPick(sel.id, $event)"
|
||||
>
|
||||
<span class="label">{{ label(sel) }}</span>
|
||||
@@ -184,21 +188,18 @@ const panelStyle = computed(() =>
|
||||
gap: 2px;
|
||||
min-height: 44px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 4px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/odds-button-normal-v1.webp') center / 100% 100% no-repeat;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.odds-btn:hover:not(.odds-btn--locked) {
|
||||
border-color: var(--primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.odds-btn.selected {
|
||||
border-color: var(--primary);
|
||||
background: #2f2a19;
|
||||
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
|
||||
background-image: url('../../assets/images/generated/odds-button-selected-v1.webp');
|
||||
}
|
||||
|
||||
.odds-btn.selected .label {
|
||||
@@ -210,8 +211,7 @@ const panelStyle = computed(() =>
|
||||
}
|
||||
|
||||
.odds-btn--locked {
|
||||
background: #111;
|
||||
border-color: #333;
|
||||
background-image: url('../../assets/images/generated/odds-button-disabled-v1.webp');
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ function onSubmitClick() {
|
||||
matchName: props.pick.eventTitle,
|
||||
marketId: '',
|
||||
marketName: props.pick.eventTitle,
|
||||
selectionName: `${props.pick.teamName} · ${props.pick.eventTitle}`,
|
||||
selectionName: `${props.pick.teamName} - ${props.pick.eventTitle}`,
|
||||
odds: oddsNum.value,
|
||||
marketType: 'OUTRIGHT',
|
||||
lineValue: null,
|
||||
@@ -304,7 +304,7 @@ function formatOdds(odds: string) {
|
||||
<template>
|
||||
<div v-if="open && pick" class="overlay" @click.self="close">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
|
||||
<template v-if="step === 'form'">
|
||||
<p class="hint">{{ t('bet.outright_enter_stake') }}</p>
|
||||
@@ -321,7 +321,7 @@ function formatOdds(odds: string) {
|
||||
}"
|
||||
>
|
||||
<template v-if="oddsDelta && !oddsDelta.suspended">
|
||||
@ {{ oddsDelta.oldOdds.toFixed(2) }} → {{ oddsDelta.newOdds.toFixed(2) }}
|
||||
@ {{ oddsDelta.oldOdds.toFixed(2) }} -> {{ oddsDelta.newOdds.toFixed(2) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
@ {{ formatOdds(currentOdds || pick.odds) }}
|
||||
@@ -357,7 +357,7 @@ function formatOdds(odds: string) {
|
||||
</div>
|
||||
|
||||
<p class="est-return">
|
||||
{{ t('history.est_return') }}:<span class="est-value">{{ estReturnText }}</span>
|
||||
{{ t('history.est_return') }}: <span class="est-value">{{ estReturnText }}</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -368,7 +368,7 @@ function formatOdds(odds: string) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm btn-gold-outline"
|
||||
class="btn-confirm"
|
||||
:disabled="loading || stake <= 0 || hasSuspendedSelection"
|
||||
@click="onSubmitClick"
|
||||
>
|
||||
@@ -378,7 +378,7 @@ function formatOdds(odds: string) {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="success-icon">✓</div>
|
||||
<h3 class="success-title">{{ t('bet.outright_success') }}</h3>
|
||||
|
||||
<div class="hero hero--compact">
|
||||
@@ -400,7 +400,7 @@ function formatOdds(odds: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-done btn-gold-outline" @click="close">
|
||||
<button type="button" class="btn-done" @click="close">
|
||||
{{ t('bet.outright_done') }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -437,27 +437,32 @@ function formatOdds(odds: string) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 16px 16px;
|
||||
background: url('../../assets/images/generated/outright-bet-modal-frame-v2.webp') center / 100% 100% no-repeat;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 30px 24px 24px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/modal-close-gold-v1.webp') center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-x:active {
|
||||
background-image: url('../../assets/images/generated/modal-close-gold-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -644,22 +649,36 @@ function formatOdds(odds: string) {
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border);
|
||||
height: 46px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/pc-betslip-btn-secondary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-cancel:active {
|
||||
background-image: url('../../assets/images/generated/pc-betslip-btn-secondary-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
height: 46px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/pc-betslip-btn-primary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: #100d04;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-confirm:active {
|
||||
background-image: url('../../assets/images/generated/pc-betslip-btn-primary-pressed-v1.webp');
|
||||
}
|
||||
|
||||
.btn-cancel:disabled,
|
||||
.btn-confirm:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -708,9 +727,17 @@ function formatOdds(odds: string) {
|
||||
|
||||
.btn-done {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
border-radius: 6px;
|
||||
height: 46px;
|
||||
padding: 11px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/pc-betslip-btn-primary-v1.webp') center / 100% 100% no-repeat;
|
||||
color: #100d04;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-done:active {
|
||||
background-image: url('../../assets/images/generated/pc-betslip-btn-primary-pressed-v1.webp');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OutrightOptionCard from './OutrightOptionCard.vue';
|
||||
import saishiImg from '../../assets/images/saishi.webp';
|
||||
import cardBg from '../../assets/images/card-bg.webp';
|
||||
import saishiImg from '../../assets/images/generated/saishi-gold-trophy-v1.webp';
|
||||
import cardBg from '../../assets/images/generated/pc-match-list-card-bg-v1.webp';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
|
||||
|
||||
@@ -87,9 +87,9 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 4px 6px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/odds-button-normal-v1.webp') center / 100% 100% no-repeat;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -99,15 +99,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.option-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-card:active {
|
||||
border-color: var(--border-gold-soft);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.option-card--disabled {
|
||||
@@ -116,12 +112,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.option-card--disabled:active {
|
||||
border-color: rgba(140, 140, 140, 0.35);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.option-card--winner {
|
||||
border-color: rgba(201, 162, 39, 0.75);
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25);
|
||||
background-image: url('../../assets/images/generated/odds-button-selected-v1.webp');
|
||||
}
|
||||
|
||||
.option-card--disabled {
|
||||
background-image: url('../../assets/images/generated/odds-button-disabled-v1.webp');
|
||||
}
|
||||
|
||||
.flag {
|
||||
|
||||
@@ -7,7 +7,7 @@ import OutrightEventSection, {
|
||||
} from './OutrightEventSection.vue';
|
||||
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
|
||||
import emptyMatchesImg from '../../assets/images/generated/empty-matches-stadium-v1.webp';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { useOutrightEvents } from '../../composables/useOutrightEvents';
|
||||
|
||||
@@ -159,16 +159,25 @@ function closeModal() {
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 48px 20px;
|
||||
padding: 34px 20px 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 14px;
|
||||
width: min(270px, 82vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
opacity: 0.86;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
|
||||
@@ -5,27 +5,84 @@ const visible = ref(false);
|
||||
const anchorX = ref(0);
|
||||
const anchorY = ref(0);
|
||||
const pendingItem = ref<SlipItem | null>(null);
|
||||
const activeCardRootId = ref<string | null>(null);
|
||||
const placement = ref<'top' | 'bottom'>('bottom');
|
||||
let closeTimer: number | undefined = undefined;
|
||||
|
||||
export function useDesktopBetPopover() {
|
||||
function openAt(event: MouseEvent, item: SlipItem) {
|
||||
const pad = 12;
|
||||
const popW = 300;
|
||||
const popH = 320;
|
||||
let x = event.clientX + pad;
|
||||
let y = event.clientY + pad;
|
||||
cancelClose();
|
||||
|
||||
const el = (event.currentTarget || event.target) as HTMLElement;
|
||||
const btn = el?.closest?.('button') || el;
|
||||
const rect = btn?.getBoundingClientRect?.();
|
||||
if (!rect) return;
|
||||
|
||||
const popW = 190;
|
||||
const popH = 170;
|
||||
const pad = 12; // increased pad to leave space for the arrow pointer
|
||||
|
||||
// Calculate center X of the button
|
||||
const btnCenterX = rect.left + rect.width / 2;
|
||||
let x = btnCenterX - popW / 2;
|
||||
|
||||
// Default: place below button (center bottom)
|
||||
let y = rect.bottom + pad;
|
||||
placement.value = 'bottom';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
x = Math.min(x, window.innerWidth - popW - pad);
|
||||
y = Math.min(y, window.innerHeight - popH - pad);
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
if (spaceBelow < popH + pad) {
|
||||
// Not enough space below, place above button (center top)
|
||||
y = rect.top - popH - pad;
|
||||
placement.value = 'top';
|
||||
}
|
||||
anchorX.value = Math.max(pad, x);
|
||||
anchorY.value = Math.max(pad, y);
|
||||
|
||||
// Keep within viewport boundaries
|
||||
x = Math.max(pad, Math.min(x, window.innerWidth - popW - pad));
|
||||
y = Math.max(pad, Math.min(y, window.innerHeight - popH - pad));
|
||||
}
|
||||
|
||||
anchorX.value = x;
|
||||
anchorY.value = y;
|
||||
pendingItem.value = item;
|
||||
const root = el?.closest?.('[data-bet-card-root]') as HTMLElement | null;
|
||||
activeCardRootId.value = root?.dataset.betCardRoot ?? null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
pendingItem.value = null;
|
||||
activeCardRootId.value = null;
|
||||
if (closeTimer !== undefined) {
|
||||
window.clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleClose(delay = 200) {
|
||||
if (closeTimer !== undefined) {
|
||||
window.clearTimeout(closeTimer);
|
||||
}
|
||||
closeTimer = window.setTimeout(() => {
|
||||
visible.value = false;
|
||||
pendingItem.value = null;
|
||||
activeCardRootId.value = null;
|
||||
closeTimer = undefined;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function cancelClose() {
|
||||
if (closeTimer !== undefined) {
|
||||
window.clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveForCard(cardRootId: string | null | undefined) {
|
||||
if (!cardRootId || !visible.value || !pendingItem.value) return false;
|
||||
return activeCardRootId.value === cardRootId;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -33,7 +90,12 @@ export function useDesktopBetPopover() {
|
||||
anchorX,
|
||||
anchorY,
|
||||
pendingItem,
|
||||
activeCardRootId,
|
||||
placement,
|
||||
openAt,
|
||||
close,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
isActiveForCard,
|
||||
};
|
||||
}
|
||||
|
||||
42
apps/player/src/composables/useHoverDropdown.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
export function useHoverDropdown(delay = 200) {
|
||||
const open = ref(false);
|
||||
let closeTimer: number | undefined;
|
||||
|
||||
function cancelClose() {
|
||||
if (closeTimer !== undefined) {
|
||||
window.clearTimeout(closeTimer);
|
||||
closeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function openHover() {
|
||||
cancelClose();
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
cancelClose();
|
||||
closeTimer = window.setTimeout(() => {
|
||||
open.value = false;
|
||||
closeTimer = undefined;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function closeNow() {
|
||||
cancelClose();
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelClose();
|
||||
});
|
||||
|
||||
return {
|
||||
open,
|
||||
openHover,
|
||||
scheduleClose,
|
||||
closeNow,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import type { OutrightEvent } from '../components/outright/OutrightEventSection.vue';
|
||||
import type { OutrightSelection } from '../components/outright/OutrightEventSection.vue';
|
||||
import { useOnLocaleChange } from './useOnLocaleChange';
|
||||
|
||||
export function useOutrightEvents() {
|
||||
const { t } = useI18n();
|
||||
const loading = ref(true);
|
||||
const CACHE_TTL_MS = 8000;
|
||||
const loading = ref(false);
|
||||
const loadError = ref('');
|
||||
const events = ref<OutrightEvent[]>([]);
|
||||
let lastLoadedAt = 0;
|
||||
let inFlight: Promise<OutrightEvent[]> | null = null;
|
||||
|
||||
type LoadOptions = {
|
||||
silent?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function useOutrightEvents() {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
function mergeOddsOnly(fresh: OutrightEvent[]) {
|
||||
const freshMap = new Map<string, OutrightEvent>();
|
||||
@@ -30,15 +38,34 @@ export function useOutrightEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load(options?: { silent?: boolean }) {
|
||||
async function load(options?: LoadOptions) {
|
||||
const hadData = events.value.length > 0;
|
||||
if (!options?.force && hadData && Date.now() - lastLoadedAt < CACHE_TTL_MS) {
|
||||
loading.value = false;
|
||||
loadError.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlight) {
|
||||
if (!hadData && !options?.silent) loading.value = true;
|
||||
try {
|
||||
await inFlight;
|
||||
} finally {
|
||||
if (!hadData && !options?.silent) loading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hadData && !options?.silent) loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const { data } = await api.get('/player/outrights');
|
||||
inFlight = api.get('/player/outrights').then(({ data }) => {
|
||||
const list = (data?.data ?? []) as OutrightEvent[];
|
||||
const fresh = list.filter((e) => e.selections?.length > 0);
|
||||
if (!hadData) {
|
||||
return list.filter((e) => e.selections?.length > 0);
|
||||
});
|
||||
const fresh = await inFlight;
|
||||
lastLoadedAt = Date.now();
|
||||
if (!hadData || options?.force) {
|
||||
events.value = fresh;
|
||||
} else {
|
||||
mergeOddsOnly(fresh);
|
||||
@@ -53,10 +80,19 @@ export function useOutrightEvents() {
|
||||
}
|
||||
} finally {
|
||||
if (!hadData && !options?.silent) loading.value = false;
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(load);
|
||||
onMounted(() => {
|
||||
void load({ silent: events.value.length > 0 });
|
||||
});
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) {
|
||||
void load({ silent: events.value.length > 0, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
|
||||
@@ -113,11 +113,31 @@ function collectAnnouncementLines(data: HomePayload | null): string[] {
|
||||
|
||||
function collectAnnouncementItems(data: HomePayload | null): PlayerAnnouncementItem[] {
|
||||
if (!data) return [];
|
||||
const source =
|
||||
data.announcements && data.announcements.length > 0
|
||||
? data.announcements
|
||||
: [...(data.ticker ?? []), ...(data.notices ?? [])];
|
||||
return source.filter((item) => item.translation?.title || item.translation?.body);
|
||||
const seen = new Set<string>();
|
||||
const merged: PlayerAnnouncementItem[] = [];
|
||||
|
||||
const pushItems = (items: PlayerAnnouncementItem[] | undefined) => {
|
||||
for (const item of items ?? []) {
|
||||
if (!item?.id || seen.has(item.id)) continue;
|
||||
if (!item.translation?.title && !item.translation?.body) continue;
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
pushItems(data.banners);
|
||||
pushItems(data.announcements);
|
||||
pushItems(data.ticker);
|
||||
pushItems(data.notices);
|
||||
|
||||
merged.sort((a, b) => {
|
||||
const timeA = a.createdAt ? Date.parse(a.createdAt) : 0;
|
||||
const timeB = b.createdAt ? Date.parse(b.createdAt) : 0;
|
||||
if (timeB !== timeA) return timeB - timeA;
|
||||
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function collectHubContentItems(data: HomePayload | null): PlayerContentItem[] {
|
||||
|
||||
@@ -24,6 +24,8 @@ export default {
|
||||
cancelled: 'Cancelled',
|
||||
error: 'Something went wrong',
|
||||
status: 'Status',
|
||||
copy: 'Copy',
|
||||
copy_success: 'Copied',
|
||||
},
|
||||
pagination: {
|
||||
total: '{total} total',
|
||||
@@ -170,6 +172,7 @@ export default {
|
||||
},
|
||||
auth:
|
||||
{ login: 'Login',
|
||||
logging_in: 'Logging in…',
|
||||
register: 'Create Account',
|
||||
logout: 'Log out',
|
||||
username: 'Username',
|
||||
@@ -310,9 +313,13 @@ export default {
|
||||
recharge_btn: 'Deposit',
|
||||
recharge_history: 'Deposit history',
|
||||
cashbacks_tab: 'Cashback',
|
||||
detail_info: 'Details',
|
||||
frozen_before: 'Frozen before',
|
||||
frozen_after: 'Frozen after',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Recharge',
|
||||
scan_to_pay: 'Scan QR code to pay',
|
||||
history: 'History',
|
||||
history_title: 'Recharge History',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
@@ -597,6 +604,9 @@ export default {
|
||||
placed_at: 'Placed at',
|
||||
detail_title: 'Bet details',
|
||||
selections: 'Selections',
|
||||
summary: 'Bet summary',
|
||||
legs_count: 'Legs',
|
||||
back_to_list: 'Back to bet list',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profile',
|
||||
@@ -652,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_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.',
|
||||
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;
|
||||
|
||||
@@ -24,6 +24,8 @@ export default {
|
||||
cancelled: 'Dibatalkan',
|
||||
error: 'Operasi gagal',
|
||||
status: 'Status',
|
||||
copy: 'Salin',
|
||||
copy_success: 'Berjaya disalin',
|
||||
},
|
||||
pagination: {
|
||||
total: '{total} jumlah',
|
||||
@@ -182,6 +184,7 @@ export default {
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
logging_in: 'Sedang log masuk…',
|
||||
register: 'Daftar Akaun',
|
||||
logout: 'Log Keluar',
|
||||
username: 'Nama Pengguna',
|
||||
@@ -322,9 +325,13 @@ export default {
|
||||
recharge_btn: 'Topup',
|
||||
recharge_history: 'Sejarah topup',
|
||||
cashbacks_tab: 'Rebat',
|
||||
detail_info: 'Butiran',
|
||||
frozen_before: 'Beku sebelum',
|
||||
frozen_after: 'Beku selepas',
|
||||
},
|
||||
recharge: {
|
||||
title: 'Topup',
|
||||
scan_to_pay: 'Imbas kod QR untuk bayar',
|
||||
history: 'Sejarah',
|
||||
history_title: 'Sejarah Topup',
|
||||
bank_transfer: 'Pindahan Bank',
|
||||
@@ -609,6 +616,9 @@ export default {
|
||||
placed_at: 'Masa pertaruhan',
|
||||
detail_title: 'Butiran pertaruhan',
|
||||
selections: 'Pilihan',
|
||||
summary: 'Ringkasan pertaruhan',
|
||||
legs_count: 'Bilangan pilihan',
|
||||
back_to_list: 'Kembali ke senarai pertaruhan',
|
||||
},
|
||||
profile: {
|
||||
edit: 'Edit Profil',
|
||||
@@ -664,5 +674,15 @@ export default {
|
||||
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_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;
|
||||
|
||||
@@ -24,6 +24,8 @@ export default {
|
||||
cancelled: '已取消',
|
||||
error: '操作失败',
|
||||
status: '状态',
|
||||
copy: '复制',
|
||||
copy_success: '复制成功',
|
||||
},
|
||||
pagination: {
|
||||
total: '共 {total} 条',
|
||||
@@ -170,6 +172,7 @@ export default {
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
logging_in: '登录中…',
|
||||
register: '注册账号',
|
||||
logout: '退出登录',
|
||||
username: '账号',
|
||||
@@ -310,9 +313,13 @@ export default {
|
||||
recharge_btn: '充值',
|
||||
recharge_history: '充值记录',
|
||||
cashbacks_tab: '返水明细',
|
||||
detail_info: '详细信息',
|
||||
frozen_before: '冻结前',
|
||||
frozen_after: '冻结后',
|
||||
},
|
||||
recharge: {
|
||||
title: '充值',
|
||||
scan_to_pay: '请扫描二维码完成支付',
|
||||
history: '记录',
|
||||
history_title: '充值记录',
|
||||
bank_transfer: '银行转账',
|
||||
@@ -597,6 +604,9 @@ export default {
|
||||
placed_at: '下注时间',
|
||||
detail_title: '注单详情',
|
||||
selections: '投注选项',
|
||||
summary: '注单概要',
|
||||
legs_count: '过关场数',
|
||||
back_to_list: '返回注单列表',
|
||||
},
|
||||
profile: {
|
||||
edit: '修改资料',
|
||||
@@ -652,5 +662,15 @@ export default {
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
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;
|
||||
|
||||
@@ -261,7 +261,7 @@ watch(
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
min-height: var(--player-header-h);
|
||||
background: rgba(17, 17, 17, 0.94);
|
||||
background: rgba(29, 27, 22, 0.88);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 120;
|
||||
}
|
||||
@@ -371,7 +371,7 @@ watch(
|
||||
box-sizing: border-box;
|
||||
min-height: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: rgba(17, 17, 17, 0.96);
|
||||
background: rgba(29, 27, 22, 0.9);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
|
||||
@@ -42,6 +42,7 @@ const router = createRouter({
|
||||
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/recharge', component: () => import('../views/RechargeView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/recharge/history', component: () => import('../views/RechargeHistoryView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/recharge/history/:id', component: () => import('../views/RechargeDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
--secondary: #1A1A1A;
|
||||
--tertiary: #000000;
|
||||
--neutral: #8E8E93;
|
||||
--bg-body: #000000;
|
||||
--bg-card: rgba(20, 20, 20, 0.65);
|
||||
--bg-hover: rgba(34, 34, 34, 0.65);
|
||||
--bg-elevated: rgba(42, 42, 42, 0.65);
|
||||
--bg-body: #080806;
|
||||
--bg-card: rgba(33, 31, 25, 0.76);
|
||||
--bg-hover: rgba(47, 44, 35, 0.82);
|
||||
--bg-elevated: rgba(58, 54, 43, 0.86);
|
||||
--text: #FFFFFF;
|
||||
--text-muted: #8E8E93;
|
||||
--border: #333333;
|
||||
--text-muted: #B7B0A0;
|
||||
--border: rgba(255, 226, 156, 0.18);
|
||||
--border-gold: #D4AF37;
|
||||
--border-gold-soft: rgba(212, 175, 55, 0.28);
|
||||
--border-w: 1px;
|
||||
@@ -209,9 +209,9 @@ body {
|
||||
/* 保留 bg 位图;用 scroll 替代 fixed,减轻移动端滚动重绘(后续可换 WebP/AVIF) */
|
||||
background-color: var(--tertiary);
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
|
||||
linear-gradient(rgba(0, 0, 0, 0.88), rgba(0, 0, 0, 0.88)),
|
||||
url('./assets/images/bg.webp');
|
||||
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(246, 210, 108, 0.2), transparent 56%),
|
||||
linear-gradient(rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.7)),
|
||||
url('./assets/images/generated/saishi-gold-trophy-v1.webp');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
@@ -223,7 +223,10 @@ body {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
background-image: url('./assets/images/pcbg.webp');
|
||||
background-image:
|
||||
radial-gradient(ellipse 90% 70% at 50% 0%, rgba(246, 210, 108, 0.14), transparent 58%),
|
||||
linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.58)),
|
||||
url('./assets/images/generated/pc-global-bg-v1.webp');
|
||||
background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* Hover Cursors and active resets */
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Desktop Interaction System
|
||||
Style: Modern Dark + Gold Accent — premium micro-interactions
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Cursor & Touch ── */
|
||||
.desktop-shell a,
|
||||
.desktop-shell button,
|
||||
.desktop-shell select,
|
||||
@@ -8,71 +13,111 @@
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
/* Scrollbar customizations */
|
||||
/* ── Focus Visible (Accessibility) ── */
|
||||
.desktop-shell a:focus-visible,
|
||||
.desktop-shell button:focus-visible,
|
||||
.desktop-shell select:focus-visible,
|
||||
.desktop-shell [role="button"]:focus-visible,
|
||||
.desktop-shell input:focus-visible,
|
||||
.desktop-shell textarea:focus-visible,
|
||||
.desktop-shell [tabindex]:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Scrollbar Customization ── */
|
||||
.desktop-shell ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.desktop-shell ::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.desktop-shell ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.desktop-shell ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-light);
|
||||
|
||||
.desktop-shell ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
transition: background var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
/* Hover highlights */
|
||||
.desktop-shell ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
/* ── Hover Highlights ── */
|
||||
.desktop-shell .hover-bg {
|
||||
transition: background-color var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
.desktop-shell .hover-bg:hover {
|
||||
background-color: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.desktop-shell .hover-gold-soft {
|
||||
transition: border-color var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
.desktop-shell .hover-gold-soft:hover {
|
||||
border-color: var(--border-gold) !important;
|
||||
box-shadow: var(--glow-gold);
|
||||
}
|
||||
|
||||
.desktop-shell .hover-gold-text {
|
||||
transition: color var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
.desktop-shell .hover-gold-text:hover {
|
||||
color: var(--primary-light) !important;
|
||||
}
|
||||
|
||||
/* Form elements styling */
|
||||
.desktop-shell input {
|
||||
/* ── Form Elements ── */
|
||||
.desktop-shell input,
|
||||
.desktop-shell textarea {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
border-radius: var(--radius);
|
||||
transition: border-color var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.desktop-shell input:focus {
|
||||
.desktop-shell input:focus,
|
||||
.desktop-shell textarea:focus {
|
||||
border-color: var(--border-gold) !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Button hover overrides */
|
||||
/* ── Button System ── */
|
||||
.desktop-shell .btn-gold-outline {
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
transition: background-color var(--duration-normal) var(--ease-smooth),
|
||||
border-color var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth),
|
||||
transform var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
.desktop-shell .btn-gold-outline:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border-color: var(--border-gold);
|
||||
box-shadow: var(--glow-gold);
|
||||
}
|
||||
.desktop-shell .btn-gold-outline:active {
|
||||
transform: none; /* No mobile shrink on PC */
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.desktop-shell .btn-primary {
|
||||
transition: filter 0.2s, transform 0.1s;
|
||||
transition: filter var(--duration-normal) var(--ease-smooth),
|
||||
transform var(--duration-fast) var(--ease-out-expo),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
.desktop-shell .btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: var(--glow-gold);
|
||||
}
|
||||
.desktop-shell .btn-primary:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Skeleton rows animation for high density PC loaders */
|
||||
/* ── Skeleton Loading Animation ── */
|
||||
@keyframes desktop-skeleton-glow {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
@@ -82,11 +127,12 @@
|
||||
background: linear-gradient(90deg, #161616 25%, #222222 37%, #161616 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: desktop-skeleton-glow 1.4s ease infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Bet Locate Pulse ── */
|
||||
@keyframes bet-locate-pulse {
|
||||
0%,
|
||||
100% {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(212, 175, 55, 0);
|
||||
}
|
||||
50% {
|
||||
@@ -98,5 +144,51 @@
|
||||
animation: bet-locate-pulse 0.6s ease 3;
|
||||
border-color: var(--border-gold) !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: var(--z-card);
|
||||
}
|
||||
|
||||
/* ── Fade-in Stagger Utility ── */
|
||||
@keyframes desktop-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-shell .anim-fade-in {
|
||||
animation: desktop-fade-in 0.5s var(--ease-out-expo) both;
|
||||
animation-delay: var(--stagger-delay, 0ms);
|
||||
}
|
||||
|
||||
/* ── Scale Press Micro-interaction ── */
|
||||
.desktop-shell .press-scale {
|
||||
transition: transform var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
.desktop-shell .press-scale:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ── Hover Lift Effect ── */
|
||||
.desktop-shell .hover-lift {
|
||||
transition: transform var(--duration-normal) var(--ease-out-expo),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
.desktop-shell .hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ── Reduced Motion Preference ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.desktop-shell *,
|
||||
.desktop-shell *::before,
|
||||
.desktop-shell *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
/* Desktop Global layout structure */
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Desktop Global layout structure
|
||||
Style: Modern Dark + Gold Accent — layered depth + glassmorphism
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
.desktop-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #000;
|
||||
background-image: url('../../assets/images/pcbg.webp');
|
||||
background-color: #0d0b08;
|
||||
background-image:
|
||||
radial-gradient(ellipse 90% 68% at 50% 0%, rgba(246, 210, 108, 0.14), transparent 58%),
|
||||
linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.58)),
|
||||
url('../../assets/images/generated/pc-global-bg-v1.webp');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle ambient overlay for depth */
|
||||
.desktop-shell::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(255, 221, 128, 0.08) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.desktop-header-wrap {
|
||||
flex-shrink: 0;
|
||||
height: var(--desktop-header-h);
|
||||
background: #111111;
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 120;
|
||||
background: #12100c url('../../assets/images/generated/pc-top-nav-bg-v1.webp') center / 100% 100% no-repeat;
|
||||
border-bottom: 0;
|
||||
z-index: var(--z-header);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
transition: background var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.desktop-marquee-wrap {
|
||||
flex-shrink: 0;
|
||||
z-index: 110;
|
||||
z-index: var(--z-sticky);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #111;
|
||||
background: rgba(26, 24, 19, 0.78);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
}
|
||||
|
||||
.desktop-shell.betslip-hidden {
|
||||
@@ -44,6 +64,8 @@
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
.desktop-main-container {
|
||||
@@ -55,7 +77,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Betting Layout: Sports bar + Left Sidebar + Center View + Right Betslip */
|
||||
/* ── Betting Layout: Sports bar + Left Sidebar + Center View + Right Betslip ── */
|
||||
.desktop-layout-betting-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -69,7 +91,9 @@
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(14, 14, 14, 0.85);
|
||||
background: rgba(26, 24, 19, 0.76);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
}
|
||||
|
||||
.desktop-layout-betting {
|
||||
@@ -82,13 +106,15 @@
|
||||
|
||||
.desktop-betting-left {
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(14, 14, 14, 0.6);
|
||||
background: rgba(26, 24, 19, 0.56);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.desktop-betting-center {
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
padding: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -96,7 +122,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Account Layout: full-width records center */
|
||||
/* ── Account Layout: full-width records center ── */
|
||||
.desktop-layout-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -112,7 +138,7 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 28px 40px 40px;
|
||||
padding: var(--space-7) var(--space-10) var(--space-10);
|
||||
}
|
||||
|
||||
.desktop-account-center > .desktop-records-page {
|
||||
@@ -120,6 +146,13 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -134,7 +167,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Marketing / Home Layout: Single Column centered */
|
||||
/* ── Marketing / Home Layout: Single Column centered ── */
|
||||
.desktop-layout-marketing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -142,7 +175,7 @@
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
padding: var(--space-5);
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
@@ -156,7 +189,7 @@
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Hub Layout: Left Sidebar (List) + Right Outlet (Detail) */
|
||||
/* ── Hub Layout: Left Sidebar (List) + Right Outlet (Detail) ── */
|
||||
.desktop-layout-hub {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
@@ -166,7 +199,9 @@
|
||||
|
||||
.desktop-hub-left {
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(14, 14, 14, 0.6);
|
||||
background: var(--desktop-sidebar-panel-bg);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -174,11 +209,11 @@
|
||||
|
||||
.desktop-hub-right {
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: rgba(10, 10, 10, 0.3);
|
||||
padding: var(--space-5);
|
||||
background: rgba(18, 16, 12, 0.18);
|
||||
}
|
||||
|
||||
/* Auth Layout: Centered container */
|
||||
/* ── Auth Layout: Centered container ── */
|
||||
.desktop-layout-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,7 +221,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 40px;
|
||||
padding: var(--space-10);
|
||||
}
|
||||
|
||||
.desktop-auth-card {
|
||||
@@ -195,6 +230,6 @@
|
||||
padding: 30px;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg), var(--glow-gold);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
/* PC account records: bets, wallet, recharge, cashbacks */
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
PC account records: bets, wallet, recharge, cashbacks
|
||||
Style: Modern Dark + Gold Accent — premium data table
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
.desktop-records-page {
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.desktop-records-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px 32px;
|
||||
gap: var(--space-5) var(--space-8);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 14px;
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -34,7 +39,7 @@
|
||||
.desktop-records-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -42,27 +47,42 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) 18px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
transition: background var(--duration-normal) var(--ease-smooth),
|
||||
border-color var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth),
|
||||
transform var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.desktop-records-action-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
border-color: var(--border-gold);
|
||||
box-shadow: var(--glow-gold);
|
||||
}
|
||||
|
||||
.desktop-records-action-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.desktop-records-action-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Filter System ── */
|
||||
.desktop-records-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -70,19 +90,20 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
gap: var(--space-3) 14px;
|
||||
}
|
||||
|
||||
.desktop-records-filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.desktop-records-filter-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -98,45 +119,60 @@
|
||||
|
||||
.desktop-records-filter-chip {
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
transition: color var(--duration-normal) var(--ease-smooth),
|
||||
border-color var(--duration-normal) var(--ease-smooth),
|
||||
background var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-records-filter-chip:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(212, 175, 55, 0.25);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.desktop-records-filter-chip.active {
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: rgba(212, 175, 55, 0.35);
|
||||
box-shadow: 0 0 8px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.desktop-records-filter-chip:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Wallet Sub Nav ── */
|
||||
.desktop-wallet-subnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.desktop-wallet-subnav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
transition: color var(--duration-normal) var(--ease-smooth),
|
||||
background var(--duration-normal) var(--ease-smooth),
|
||||
border-color var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.desktop-wallet-subnav-link:hover {
|
||||
@@ -150,15 +186,24 @@
|
||||
border-color: rgba(212, 175, 55, 0.28);
|
||||
}
|
||||
|
||||
.desktop-wallet-subnav-link:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Panel & Table ── */
|
||||
.desktop-records-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: rgba(18, 18, 18, 0.94);
|
||||
background: rgba(18, 18, 18, 0.92);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.desktop-records-table-wrap {
|
||||
@@ -170,34 +215,53 @@
|
||||
.desktop-records-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* ── Table Header ── */
|
||||
.desktop-records-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 12px 16px;
|
||||
z-index: var(--z-sticky);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
background: #121212;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
background: rgba(12, 12, 12, 0.97);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Gold accent line under thead */
|
||||
.desktop-records-table thead tr::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.desktop-records-table th.num-th {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Table Body ── */
|
||||
.desktop-records-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
transition: background var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.desktop-records-table tbody tr:nth-child(even) {
|
||||
@@ -205,42 +269,54 @@
|
||||
}
|
||||
|
||||
.desktop-records-table tbody tr.hover-row {
|
||||
transition: background 0.15s;
|
||||
transition: background var(--duration-fast) var(--ease-smooth),
|
||||
box-shadow var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.desktop-records-table tbody tr.hover-row:hover {
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
.desktop-records-table tbody tr.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.desktop-records-table tbody tr.hover-row:hover,
|
||||
.desktop-records-table tbody tr.clickable-row:hover {
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
/* ── Cell Styles ── */
|
||||
.desktop-records-table .id-cell {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.desktop-records-table .num-cell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.desktop-records-table .date-cell {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desktop-records-table .link-cell {
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.desktop-records-table .link-cell:hover {
|
||||
text-decoration: underline;
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Pagination ── */
|
||||
.desktop-records-pagination {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(10, 10, 10, 0.35);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: rgba(10, 10, 10, 0.5);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
}
|
||||
|
||||
/* ── Loading & Empty States ── */
|
||||
.desktop-records-loading,
|
||||
.desktop-records-empty {
|
||||
display: flex;
|
||||
@@ -250,9 +326,10 @@
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 48px 24px;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -264,15 +341,15 @@
|
||||
|
||||
.desktop-records-table-empty {
|
||||
text-align: center;
|
||||
padding: 56px 16px !important;
|
||||
padding: 56px var(--space-4) !important;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.desktop-records-table-empty .empty-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
@@ -283,27 +360,77 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
padding: 48px 24px;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
}
|
||||
|
||||
/* ── Status Badges ── */
|
||||
.desktop-records-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
transition: transform var(--duration-fast) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.desktop-records-status.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
|
||||
.desktop-records-status.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
|
||||
.desktop-records-status.status-pending { background: rgba(212, 175, 55, 0.12); color: var(--primary-light); }
|
||||
.desktop-records-status.status-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
|
||||
.desktop-records-status.status-cancelled { background: rgba(100, 100, 100, 0.1); color: #777; }
|
||||
.desktop-records-status.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
|
||||
.desktop-records-status::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-records-status.status-won {
|
||||
background: rgba(52, 199, 89, 0.12);
|
||||
color: #4cd964;
|
||||
}
|
||||
.desktop-records-status.status-won::before { background: #4cd964; }
|
||||
|
||||
.desktop-records-status.status-lost {
|
||||
background: rgba(255, 69, 58, 0.12);
|
||||
color: #ff453a;
|
||||
}
|
||||
.desktop-records-status.status-lost::before { background: #ff453a; }
|
||||
|
||||
.desktop-records-status.status-pending {
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
.desktop-records-status.status-pending::before {
|
||||
background: var(--primary-light);
|
||||
animation: pulse-dot 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.desktop-records-status.status-push {
|
||||
background: rgba(100, 100, 100, 0.12);
|
||||
color: #aaa;
|
||||
}
|
||||
.desktop-records-status.status-push::before { background: #aaa; }
|
||||
|
||||
.desktop-records-status.status-cancelled {
|
||||
background: rgba(100, 100, 100, 0.1);
|
||||
color: #777;
|
||||
}
|
||||
.desktop-records-status.status-cancelled::before { background: #777; }
|
||||
|
||||
.desktop-records-status.status-default {
|
||||
background: rgba(100, 100, 100, 0.1);
|
||||
color: #888;
|
||||
}
|
||||
.desktop-records-status.status-default::before { background: #888; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
/* ── Amount Colors ── */
|
||||
.desktop-records-amount.pos { color: var(--primary-light); }
|
||||
.desktop-records-amount.neg { color: var(--danger); }
|
||||
.desktop-records-amount.zero { color: var(--text-muted); }
|
||||
|
||||
@@ -1,45 +1,123 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Desktop Design Tokens — TheBet365
|
||||
Style: Modern Dark + Gold Accent (Cinema Mobile)
|
||||
Typography: Russo One / Chakra Petch
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Google Fonts Import ── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@300;400;500;600;700&family=Russo+One&display=swap');
|
||||
|
||||
.desktop-shell {
|
||||
/* Color Palette overrides */
|
||||
/* ── Color Palette ── */
|
||||
--primary: #D4AF37;
|
||||
--primary-dark: #A8841F;
|
||||
--primary-light: #F0D875;
|
||||
--secondary: #141414;
|
||||
--tertiary: #0A0A0A;
|
||||
--secondary: #1c1a15;
|
||||
--tertiary: #0f0d09;
|
||||
--neutral: #8E8E93;
|
||||
--bg-body: #050505;
|
||||
--bg-card: rgba(20, 20, 20, 0.95);
|
||||
--bg-hover: rgba(38, 38, 38, 0.95);
|
||||
--bg-elevated: rgba(45, 45, 45, 0.95);
|
||||
--bg-body: #0d0b08;
|
||||
--bg-card: rgba(32, 30, 24, 0.82);
|
||||
--bg-card-hover: rgba(44, 40, 31, 0.9);
|
||||
--bg-hover: rgba(52, 48, 38, 0.9);
|
||||
--bg-elevated: rgba(62, 57, 45, 0.92);
|
||||
--bg-surface: rgba(28, 26, 21, 0.8);
|
||||
--text: #FFFFFF;
|
||||
--text-muted: #8E8E93;
|
||||
--border: #262626;
|
||||
--text-secondary: #D3CBB8;
|
||||
--text-muted: #B5AD9B;
|
||||
--border: rgba(255, 226, 156, 0.16);
|
||||
--border-subtle: rgba(255, 235, 185, 0.1);
|
||||
--border-gold: #D4AF37;
|
||||
--border-gold-soft: rgba(212, 175, 55, 0.25);
|
||||
--border-w: 1px;
|
||||
--border-w-thick: 1px;
|
||||
--danger: #FF453A;
|
||||
--success: #34C759;
|
||||
--warning: #FFD60A;
|
||||
--radius: 4px;
|
||||
--radius-sm: 2px;
|
||||
--shadow: 0 4px 16px rgba(0, 0, 0, 0.65);
|
||||
--shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.15);
|
||||
--glow-gold: 0 0 6px rgba(212, 175, 55, 0.15);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
/* PC Dimensioning */
|
||||
/* ── Shadows & Elevation ── */
|
||||
--shadow: 0 4px 16px rgba(0, 0, 0, 0.65);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
|
||||
--shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.15);
|
||||
--shadow-gold-lg: 0 4px 24px rgba(212, 175, 55, 0.2);
|
||||
--glow-gold: 0 0 6px rgba(212, 175, 55, 0.15);
|
||||
--glow-gold-md: 0 0 16px rgba(212, 175, 55, 0.2);
|
||||
--glow-gold-lg: 0 0 24px rgba(212, 175, 55, 0.25);
|
||||
|
||||
/* ── Backdrop Blur ── */
|
||||
--blur-sm: blur(8px);
|
||||
--blur-md: blur(16px);
|
||||
--blur-lg: blur(24px);
|
||||
|
||||
/* ── PC Dimensioning ── */
|
||||
--desktop-header-h: 56px;
|
||||
--desktop-left-sidebar-w: 220px;
|
||||
--desktop-right-betslip-w: 288px;
|
||||
--desktop-right-betslip-expanded-w: 288px;
|
||||
--desktop-right-betslip-collapsed-w: 40px;
|
||||
|
||||
/* Desktop specific added vars */
|
||||
--desktop-bg: #050505;
|
||||
--desktop-border: #262626;
|
||||
--desktop-sidebar-bg: rgba(20, 20, 20, 0.95);
|
||||
/* ── Desktop specific vars ── */
|
||||
--desktop-bg: #0d0b08;
|
||||
--desktop-border: rgba(255, 226, 156, 0.16);
|
||||
--desktop-sidebar-bg: rgba(30, 28, 23, 0.82);
|
||||
--desktop-sidebar-panel-bg: linear-gradient(rgba(18, 16, 12, 0.52), rgba(18, 16, 12, 0.64)), url('../../assets/images/generated/pc-sidebar-bg-v1.webp') no-repeat center / cover;
|
||||
|
||||
/* ── Spacing Scale (8dp rhythm) ── */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-7: 28px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-display: 'Russo One', 'Chakra Petch', sans-serif;
|
||||
--font-body: 'Chakra Petch', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Consolas', 'Chakra Petch', monospace;
|
||||
|
||||
--text-xs: 10px;
|
||||
--text-sm: 11px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 22px;
|
||||
--text-3xl: 28px;
|
||||
|
||||
/* Reset typography inside desktop */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* ── Transition Timings ── */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-smooth: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-smooth: 300ms;
|
||||
--duration-emphasis: 400ms;
|
||||
|
||||
/* ── Z-index Scale ── */
|
||||
--z-base: 0;
|
||||
--z-card: 10;
|
||||
--z-sticky: 20;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 100;
|
||||
--z-popover: 110;
|
||||
--z-header: 120;
|
||||
--z-tooltip: 1000;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
|
||||
import loginBg from '../assets/images/h5bg.webp';
|
||||
|
||||
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -98,11 +100,11 @@ function goLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
|
||||
<div class="login-lang">
|
||||
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
|
||||
<div v-if="!props.desktop" class="login-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<form @submit.prevent="submit" class="login-form" :class="{ 'ps-gold-frame': !props.desktop }">
|
||||
<h2 class="form-title">{{ t('auth.forgot_password_title') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
@@ -203,6 +205,17 @@ function goLogin() {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.login-page--desktop {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
@@ -212,6 +225,16 @@ function goLogin() {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.login-page--desktop .login-form {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 16px;
|
||||
@@ -220,12 +243,24 @@ function goLogin() {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-page--desktop .form-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
text-align: left;
|
||||
color: var(--primary-light, #f0d875);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
@@ -233,16 +268,27 @@ label {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.login-page--desktop label {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
caret-color: var(--primary-light, #f0d875);
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
@@ -254,6 +300,41 @@ label {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input {
|
||||
height: 46px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:focus {
|
||||
border-color: var(--border-gold, #d4af37);
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.06), transparent 72%),
|
||||
#0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08),
|
||||
0 0 14px rgba(212, 175, 55, 0.14);
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:-webkit-autofill,
|
||||
.login-page--desktop .field-input:-webkit-autofill:hover,
|
||||
.login-page--desktop .field-input:-webkit-autofill:focus {
|
||||
border-color: var(--border-gold, #d4af37);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
box-shadow:
|
||||
0 0 0 1000px #0d0d0d inset,
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08);
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:hover:not(:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -277,6 +358,19 @@ label {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-secondary {
|
||||
height: 46px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-secondary:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
@@ -290,6 +384,20 @@ label {
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login {
|
||||
margin-top: 10px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold, #d4af37);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
@@ -306,6 +414,23 @@ label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip {
|
||||
margin-top: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.btn-skip:active {
|
||||
@@ -319,4 +444,12 @@ label {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.login-page--desktop .error {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
border-left: 2px solid var(--danger, #ff453a);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,11 @@ import { useAppLocale } from '../composables/useAppLocale';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import loginBg from '../assets/images/h5bg.webp';
|
||||
|
||||
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
|
||||
|
||||
type LoginMode = 'account' | 'phone';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -85,11 +88,20 @@ function goRegister() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
|
||||
<div class="login-lang">
|
||||
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
|
||||
<div v-if="!props.desktop" class="login-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="login-form"
|
||||
:class="{ 'ps-gold-frame': !props.desktop, 'is-busy': loading }"
|
||||
>
|
||||
<div v-if="loading" class="form-busy-overlay" aria-hidden="true">
|
||||
<GoldSpinner :size="40" :active="true" />
|
||||
<p class="form-busy-text">{{ t('auth.logging_in') }}</p>
|
||||
</div>
|
||||
<h2 v-if="props.desktop" class="form-title">{{ t('auth.login') }}</h2>
|
||||
<div v-if="loginMode === 'account'" class="field">
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input
|
||||
@@ -98,6 +110,7 @@ function goRegister() {
|
||||
required
|
||||
autocomplete="username"
|
||||
maxlength="32"
|
||||
:disabled="loading"
|
||||
:placeholder="t('auth.username_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +125,7 @@ function goRegister() {
|
||||
type="tel"
|
||||
required
|
||||
autocomplete="tel-national"
|
||||
:disabled="loading"
|
||||
:placeholder="t('auth.phone_local_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
@@ -119,28 +133,43 @@ function goRegister() {
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.password') }}</label>
|
||||
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
|
||||
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" :disabled="loading" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-forgot" @click="goForgotPassword">
|
||||
<button type="button" class="btn-forgot" :disabled="loading" @click="goForgotPassword">
|
||||
{{ t('auth.forgot_password') }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn-mode-switch" @click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-mode-switch"
|
||||
:disabled="loading"
|
||||
@click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')"
|
||||
>
|
||||
{{ loginMode === 'account' ? t('auth.login_by_phone') : t('auth.login_by_account') }}
|
||||
</button>
|
||||
|
||||
<RobotVerify ref="captchaRef" />
|
||||
<p v-if="resetSuccess" class="success">{{ t('auth.reset_success') }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||
{{ t('auth.login') }}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-login btn-gold-outline"
|
||||
:class="{ 'is-loading': loading }"
|
||||
:disabled="loading"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<span v-if="loading" class="btn-inline-loading">
|
||||
<GoldSpinner :size="18" :active="true" />
|
||||
{{ t('auth.logging_in') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.login') }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-skip" @click="goRegister">
|
||||
<button type="button" class="btn-skip" :disabled="loading" @click="goRegister">
|
||||
{{ t('auth.go_register') }}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||
<button v-if="!props.desktop" type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -170,13 +199,80 @@ function goRegister() {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ===== Desktop mode: fields only (card shell in DesktopAuthLayout) ===== */
|
||||
.login-page--desktop {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light, #f0d875);
|
||||
text-align: left;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.login-form.is-busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: inherit;
|
||||
background: rgba(8, 8, 8, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.form-busy-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(240, 216, 117, 0.95);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.btn-inline-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.login-page--desktop .login-form {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -185,16 +281,26 @@ function goRegister() {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
caret-color: var(--primary-light, #f0d875);
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
@@ -202,6 +308,42 @@ function goRegister() {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Desktop: visible focus ring per UX guideline */
|
||||
.login-page--desktop .field-input {
|
||||
height: 46px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:focus {
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.06), transparent 72%),
|
||||
#0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08),
|
||||
0 0 14px rgba(212, 175, 55, 0.14);
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:-webkit-autofill,
|
||||
.login-page--desktop .field-input:-webkit-autofill:hover,
|
||||
.login-page--desktop .field-input:-webkit-autofill:focus {
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
box-shadow:
|
||||
0 0 0 1000px #0d0d0d inset,
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08);
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:hover:not(:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -220,6 +362,11 @@ label {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.login-page--desktop label {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.btn-mode-switch {
|
||||
align-self: flex-start;
|
||||
margin: -2px 0 0;
|
||||
@@ -230,6 +377,11 @@ label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-mode-switch:hover {
|
||||
color: rgba(240, 216, 117, 0.95);
|
||||
}
|
||||
|
||||
.btn-forgot {
|
||||
@@ -242,12 +394,17 @@ label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-forgot:active {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-forgot:hover {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.btn-mode-switch:active {
|
||||
color: rgba(240, 216, 117, 0.95);
|
||||
}
|
||||
@@ -259,6 +416,28 @@ label {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login {
|
||||
margin-top: 10px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
@@ -266,6 +445,21 @@ label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.btn-login.is-loading:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-forgot:disabled,
|
||||
.btn-mode-switch:disabled,
|
||||
.btn-skip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-skip {
|
||||
margin-top: 2px;
|
||||
padding: 8px 14px;
|
||||
@@ -275,12 +469,29 @@ label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-skip:active {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip {
|
||||
margin-top: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.btn-skip-light {
|
||||
position: absolute;
|
||||
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
@@ -304,6 +515,15 @@ label {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
animation: login-fade-in 0.25s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .error {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
border-left: 2px solid var(--danger, #FF453A);
|
||||
}
|
||||
|
||||
.success {
|
||||
@@ -312,5 +532,30 @@ label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
animation: login-fade-in 0.25s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .success {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(110, 231, 160, 0.08);
|
||||
border-left: 2px solid #6ee7a0;
|
||||
}
|
||||
|
||||
@keyframes login-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Respect reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -364,12 +364,19 @@ const showPhaseWatermark = computed(
|
||||
.receipt {
|
||||
position: relative;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(18, 18, 18, 0.96);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.07), transparent 30%),
|
||||
rgba(18, 17, 14, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.receipt > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* result strip */
|
||||
.receipt-result {
|
||||
display: flex;
|
||||
@@ -533,7 +540,7 @@ const showPhaseWatermark = computed(
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: rgba(255, 242, 198, 0.045);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePlayerMatches } from '../composables/usePlayerMatches';
|
||||
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
|
||||
import MatchBetCard from '../components/MatchBetCard.vue';
|
||||
import OutrightPanel from '../components/outright/OutrightPanel.vue';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import emptyMatchesImg from '../assets/images/generated/empty-matches-stadium-v1.webp';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
@@ -452,7 +452,6 @@ function goMatch(id: string) {
|
||||
.bet-page {
|
||||
margin: 0 -16px;
|
||||
padding-bottom: 8px;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Tab panel toggle: avoid display:none to prevent browser releasing image resources */
|
||||
@@ -772,16 +771,25 @@ function goMatch(id: string) {
|
||||
.state,
|
||||
.placeholder,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 48px 20px;
|
||||
padding: 34px 20px 44px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 14px;
|
||||
width: min(270px, 82vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
opacity: 0.86;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onActivated, computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/card-bg.webp';
|
||||
import emptyMatchesImg from '../assets/images/generated/empty-matches-stadium-v1.webp';
|
||||
import vsImg from '../assets/images/generated/vs-gold-alpha-v1.webp';
|
||||
import hotCardBg from '../assets/images/generated/match-card-bg-gold-stadium-v1.webp';
|
||||
import upcomingCardBg from '../assets/images/generated/match-card-bg-gold-tunnel-v1.webp';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import HomeAnnouncementCard from '../components/HomeAnnouncementCard.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
@@ -16,7 +16,8 @@ import type { PlayerHomeMatch } from '../composables/usePlayerHome';
|
||||
|
||||
type HotTab = 'hot' | 'upcoming';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const hotMatchCardBg = `url(${hotCardBg})`;
|
||||
const upcomingMatchCardBg = `url(${upcomingCardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||
@@ -54,10 +55,16 @@ function formatKickoff(startTime: string) {
|
||||
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function matchCardStyle(tab: HotTab) {
|
||||
return {
|
||||
'--match-card-bg': tab === 'hot' ? hotMatchCardBg : upcomingMatchCardBg,
|
||||
};
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mobile-home">
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="pullIndicatorStyle()"
|
||||
@@ -67,10 +74,6 @@ function formatKickoff(startTime: string) {
|
||||
|
||||
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
|
||||
|
||||
<div class="home-card-wrapper">
|
||||
<HomeAnnouncementCard />
|
||||
</div>
|
||||
|
||||
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,6 +101,7 @@ function formatKickoff(startTime: string) {
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
|
||||
:style="matchCardStyle(activeTab)"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
@@ -153,8 +157,9 @@ function formatKickoff(startTime: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-card-wrapper {
|
||||
margin: 12px 16px;
|
||||
.mobile-home {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
@@ -166,33 +171,79 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.hot-tabs {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.hot-tab {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
aspect-ratio: 1024 / 275;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
transition: color 0.2s ease, transform 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.hot-tab::before,
|
||||
.hot-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.hot-tab::before {
|
||||
z-index: -2;
|
||||
background: url('../assets/images/generated/home-tab-frame-gold-v1.webp') center / 100% 100% no-repeat;
|
||||
opacity: 0.58;
|
||||
filter: saturate(0.86) brightness(0.76);
|
||||
}
|
||||
|
||||
.hot-tab::after {
|
||||
z-index: -1;
|
||||
inset: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hot-tab.active {
|
||||
font-weight: 700;
|
||||
background: rgba(244, 162, 97, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-light);
|
||||
text-shadow: 0 0 10px rgba(247, 211, 101, 0.32);
|
||||
}
|
||||
|
||||
.hot-tab.active::before,
|
||||
.hot-tab:active::before {
|
||||
opacity: 1;
|
||||
filter: saturate(1.08) brightness(1.02);
|
||||
}
|
||||
|
||||
.hot-tab.active::after {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.hot-tab:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hot-tab:focus-visible::before {
|
||||
filter: drop-shadow(0 0 6px rgba(247, 211, 101, 0.45));
|
||||
}
|
||||
|
||||
.hot-tab:active {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.match-card {
|
||||
@@ -218,8 +269,8 @@ function formatKickoff(startTime: string) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
background: var(--match-card-bg) center / 100% 100% no-repeat;
|
||||
opacity: 0.28;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -232,7 +283,7 @@ function formatKickoff(startTime: string) {
|
||||
.match-info,
|
||||
.match-flags {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
@@ -445,15 +496,24 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 40px 20px;
|
||||
padding: 30px 20px 36px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 14px;
|
||||
width: min(260px, 82vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
opacity: 0.86;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@ import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue'
|
||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import vsImg from '../assets/images/generated/vs-gold-alpha-v1.webp';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
@@ -911,18 +911,36 @@ function onPickSelection(selId: string, marketId: string) {
|
||||
}
|
||||
|
||||
.my-bets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(2, minmax(72px, auto));
|
||||
grid-auto-columns: minmax(158px, calc((100% - 6px) / 2));
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 2px;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.my-bets-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.my-bet-card {
|
||||
background: #1c1c1c;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 9px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.my-bet-card:active {
|
||||
@@ -931,27 +949,29 @@ function onPickSelection(selId: string, marketId: string) {
|
||||
|
||||
.bet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bet-type {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bet-status {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -972,18 +992,30 @@ function onPickSelection(selId: string, marketId: string) {
|
||||
|
||||
.bet-footer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bet-stake {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bet-return {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 340px) {
|
||||
.my-bets-list {
|
||||
grid-auto-columns: minmax(148px, calc((100% - 6px) / 2));
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import BetStatsPanel from '../components/BetStatsPanel.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import emptyBetsImg from '../assets/images/generated/empty-bets-ticket-v1.webp';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -152,7 +153,10 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('history.empty') }}</div>
|
||||
<div v-else class="state empty-state">
|
||||
<img :src="emptyBetsImg" alt="" class="empty-img" />
|
||||
<span>{{ t('history.empty') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -224,6 +228,21 @@ const pullIndicatorStyle = () => ({
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: min(320px, 86vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
opacity: 0.92;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
353
apps/player/src/views/MobileRechargeDetailView.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
auditActionTone,
|
||||
auditActorSecondary,
|
||||
formatDepositAuditRemark,
|
||||
shouldShowAuditRejectInTimeline,
|
||||
} from '../utils/depositAuditDisplay';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
interface DepositAuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
statusBefore: string | null;
|
||||
statusAfter: string;
|
||||
amount: string | null;
|
||||
approvedAmount: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DepositOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
paymentMethodId?: string;
|
||||
methodType: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
approvedAmount: string | null;
|
||||
rejectReason: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
reviewedAt: string | null;
|
||||
paymentMethodName: string | null;
|
||||
auditLogs?: DepositAuditLog[];
|
||||
}
|
||||
|
||||
const detail = ref<DepositOrder | null>(null);
|
||||
const loading = ref(true);
|
||||
const notFound = ref(false);
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
notFound.value = false;
|
||||
try {
|
||||
const { data } = await api.get(`/player/deposit-orders/${route.params.id}`);
|
||||
if (!data.data) {
|
||||
notFound.value = true;
|
||||
return;
|
||||
}
|
||||
detail.value = data.data;
|
||||
} catch {
|
||||
notFound.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail);
|
||||
|
||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: loadDetail,
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push('/wallet/recharge/history');
|
||||
}
|
||||
|
||||
function statusClass(s: string) {
|
||||
if (s === 'APPROVED') return 'status-approved';
|
||||
if (s === 'REJECTED') return 'status-rejected';
|
||||
return 'status-pending';
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
if (s === 'APPROVED') return t('recharge.status_approved');
|
||||
if (s === 'REJECTED') return t('recharge.status_rejected');
|
||||
return t('recharge.status_pending');
|
||||
}
|
||||
|
||||
function reapply(order: DepositOrder) {
|
||||
const query: Record<string, string> = {
|
||||
orderId: order.id,
|
||||
methodType: order.methodType,
|
||||
amount: order.amount,
|
||||
};
|
||||
if (order.paymentMethodId) query.methodId = order.paymentMethodId;
|
||||
router.push({ path: '/wallet/recharge', query });
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function orderNote(order: DepositOrder): { label: string; text: string } | null {
|
||||
const rejectReason = normalizeText(order.rejectReason);
|
||||
const remark = normalizeText(order.remark);
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
const text = rejectReason || remark;
|
||||
if (!text) return null;
|
||||
return { label: t('recharge.reject_reason'), text };
|
||||
}
|
||||
|
||||
if (remark) return { label: t('recharge.remark'), text: remark };
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderNoteLine(order: DepositOrder) {
|
||||
const note = orderNote(order);
|
||||
return note ? `${note.label}: ${note.text}` : null;
|
||||
}
|
||||
|
||||
function auditActionLabel(action: string) {
|
||||
const key = `recharge.audit_${action.toLowerCase()}` as const;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : action;
|
||||
}
|
||||
|
||||
function auditStepClass(action: string) {
|
||||
return `audit-step--${auditActionTone(action)}`;
|
||||
}
|
||||
|
||||
function formatAuditTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatOrderTime(iso: string) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function auditRemarkForTimeline(log: DepositAuditLog, order: DepositOrder) {
|
||||
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
|
||||
return null;
|
||||
}
|
||||
return formatDepositAuditRemark(log, t);
|
||||
}
|
||||
|
||||
function auditLogsForDisplay(order: DepositOrder) {
|
||||
return [...(order.auditLogs ?? [])]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map((log) => ({
|
||||
log,
|
||||
actor: auditActorSecondary(log, t),
|
||||
remark: auditRemarkForTimeline(log, order),
|
||||
}));
|
||||
}
|
||||
|
||||
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
|
||||
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
|
||||
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recharge-detail-page">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h2>{{ t('recharge.order_detail') }}</h2>
|
||||
<span class="header-spacer" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pull-indicator"
|
||||
:style="{ height: `${pullDistance}px`, opacity: Math.min(pullDistance / 48, 1) }"
|
||||
>
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="notFound || !detail" class="empty">{{ t('common.not_found') }}</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="summary-card">
|
||||
<div class="summary-head">
|
||||
<span class="method-badge" :class="detail.methodType === 'BANK' ? 'bank' : 'usdt'">
|
||||
{{ detail.methodType }}
|
||||
</span>
|
||||
<span :class="['status-badge', statusClass(detail.status)]">{{ statusLabel(detail.status) }}</span>
|
||||
</div>
|
||||
<div class="order-no">{{ detail.orderNo }}</div>
|
||||
<div class="order-amount">{{ formatMoney(detail.amount, locale) }}</div>
|
||||
<div
|
||||
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
|
||||
class="approved-amount"
|
||||
>
|
||||
{{ t('recharge.credited') }}: {{ formatMoney(detail.approvedAmount, locale) }}
|
||||
</div>
|
||||
<div class="method-name">{{ detail.paymentMethodName || '-' }}</div>
|
||||
<div class="time-row">
|
||||
<span class="time-label">{{ t('recharge.apply_time') }}</span>
|
||||
<span class="time-value">{{ formatOrderTime(detail.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.reviewedAt" class="time-row">
|
||||
<span class="time-label">{{ t('recharge.review_time') }}</span>
|
||||
<span class="time-value">{{ formatOrderTime(detail.reviewedAt) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderNoteLine(detail)"
|
||||
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
|
||||
>
|
||||
{{ orderNoteLine(detail) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.auditLogs?.length" class="audit-card">
|
||||
<h3 class="audit-title">{{ t('recharge.audit_title') }}</h3>
|
||||
<div class="audit-track">
|
||||
<div
|
||||
v-for="(entry, logIdx) in auditLogsForDisplay(detail)"
|
||||
:key="entry.log.id"
|
||||
class="audit-step"
|
||||
:class="auditStepClass(entry.log.action)"
|
||||
>
|
||||
<div class="audit-step-rail" aria-hidden="true">
|
||||
<span class="audit-dot" />
|
||||
<span v-if="logIdx < auditLogsForDisplay(detail).length - 1" class="audit-line" />
|
||||
</div>
|
||||
<div class="audit-step-body">
|
||||
<div class="audit-step-head">
|
||||
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
|
||||
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
|
||||
<p
|
||||
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
|
||||
class="audit-step-credited"
|
||||
>
|
||||
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
|
||||
</p>
|
||||
<div v-if="entry.remark?.kind === 'reject'" class="audit-step-box audit-step-box--reject">
|
||||
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
|
||||
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
|
||||
</div>
|
||||
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
|
||||
{{ auditNoteDisplayText(entry.remark) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="detail.status === 'REJECTED'"
|
||||
type="button"
|
||||
class="reapply-btn btn-gold-outline"
|
||||
@click="reapply(detail)"
|
||||
>
|
||||
{{ t('recharge.reapply') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recharge-detail-page { padding: 0 0 24px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
|
||||
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; flex: 1; text-align: center; }
|
||||
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
|
||||
.header-spacer { width: 40px; }
|
||||
.state, .empty { display: flex; justify-content: center; padding: 48px 16px; color: #666; font-weight: 600; }
|
||||
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
|
||||
|
||||
.summary-card,
|
||||
.audit-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(rgba(22, 20, 15, 0.74), rgba(18, 16, 12, 0.84)),
|
||||
url('../assets/images/generated/h5-recharge-detail-panel-v1.webp') center / 100% 100% no-repeat;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.summary-card > *,
|
||||
.audit-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.method-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
|
||||
.method-badge.bank { background: rgba(212,175,55,0.15); color: var(--primary-light); }
|
||||
.method-badge.usdt { background: rgba(38,161,123,0.15); color: #26a17b; }
|
||||
.status-badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
|
||||
.status-approved { background: rgba(52,199,89,0.15); color: #4cd964; }
|
||||
.status-rejected { background: rgba(255,69,58,0.15); color: #ff453a; }
|
||||
.status-pending { background: rgba(212,175,55,0.15); color: var(--primary-light); }
|
||||
.order-no { font-size: 11px; color: #888; font-family: monospace; margin-bottom: 4px; }
|
||||
.order-amount { font-size: 24px; font-weight: 800; color: var(--text); margin-bottom: 4px; }
|
||||
.approved-amount { font-size: 12px; color: #7eb87a; margin-bottom: 8px; }
|
||||
.method-name { font-size: 12px; color: #888; margin-bottom: 8px; }
|
||||
.time-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
|
||||
.time-label { color: #666; }
|
||||
.time-value { color: #aaa; }
|
||||
.reject-reason { margin-top: 8px; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(245,108,108,0.22); font-size: 12px; color: #f56c6c; line-height: 1.45; word-break: break-word; }
|
||||
.order-remark { margin-top: 8px; font-size: 12px; color: #888; line-height: 1.45; }
|
||||
|
||||
.audit-title { margin: 0 0 12px; font-size: 14px; font-weight: 700; color: #ccc; }
|
||||
.audit-track { display: flex; flex-direction: column; }
|
||||
.audit-step { display: flex; gap: 8px; }
|
||||
.audit-step-rail { flex: 0 0 10px; display: flex; flex-direction: column; align-items: center; padding-top: 4px; }
|
||||
.audit-dot { width: 5px; height: 5px; border-radius: 50%; background: #555; flex-shrink: 0; }
|
||||
.audit-line { flex: 1; width: 1px; min-height: 10px; margin: 3px 0; background: #2a2a2a; }
|
||||
.audit-step-body { flex: 1; min-width: 0; padding-bottom: 12px; }
|
||||
.audit-step:last-child .audit-step-body { padding-bottom: 0; }
|
||||
.audit-step-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.audit-step-title { font-size: 12px; font-weight: 600; color: #ccc; }
|
||||
.audit-step-time { font-size: 10px; color: #666; white-space: nowrap; }
|
||||
.audit-step-actor { margin: 2px 0 0; font-size: 11px; color: #999; }
|
||||
.audit-step-credited { margin: 3px 0 0; font-size: 11px; font-weight: 500; color: #7eb87a; }
|
||||
.audit-step-note { margin: 4px 0 0; font-size: 11px; color: #888; line-height: 1.45; word-break: break-word; }
|
||||
.audit-step-box { margin-top: 4px; padding: 6px 8px; border-radius: 6px; display: flex; flex-direction: column; gap: 1px; word-break: break-word; }
|
||||
.audit-step-box--reject { border: 1px solid rgba(245,108,108,0.22); }
|
||||
.audit-step-box-label { font-size: 10px; font-weight: 500; color: #c07070; }
|
||||
.audit-step-box-text { font-size: 11px; color: #b08888; }
|
||||
.audit-step--submitted .audit-dot { background: #c9a227; }
|
||||
.audit-step--approved .audit-dot { background: #4cd964; }
|
||||
.audit-step--rejected .audit-dot { background: #ff453a; }
|
||||
.audit-step--revoked .audit-dot { background: #888; }
|
||||
.audit-step--reopened .audit-dot { background: #c9a227; }
|
||||
|
||||
.reapply-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -6,34 +6,15 @@ import api from '../api';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
auditActionTone,
|
||||
auditActorSecondary,
|
||||
formatDepositAuditRemark,
|
||||
shouldShowAuditRejectInTimeline,
|
||||
} from '../utils/depositAuditDisplay';
|
||||
import { useDepositNotifications } from '../composables/useDepositNotifications';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { trackPendingOrder, pollOnce } = useDepositNotifications();
|
||||
|
||||
interface DepositAuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
statusBefore: string | null;
|
||||
statusAfter: string;
|
||||
amount: string | null;
|
||||
approvedAmount: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DepositOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
paymentMethodId?: string;
|
||||
methodType: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
@@ -43,7 +24,7 @@ interface DepositOrder {
|
||||
createdAt: string;
|
||||
reviewedAt: string | null;
|
||||
paymentMethodName: string | null;
|
||||
auditLogs?: DepositAuditLog[];
|
||||
auditLogs?: { id: string }[];
|
||||
}
|
||||
|
||||
const items = ref<DepositOrder[]>([]);
|
||||
@@ -56,8 +37,6 @@ const hasMore = ref(true);
|
||||
const sentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const selectedOrder = ref<DepositOrder | null>(null);
|
||||
|
||||
async function fetchOrders(p = 1) {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
@@ -128,103 +107,31 @@ function goRecharge() {
|
||||
}
|
||||
|
||||
function openDetail(order: DepositOrder) {
|
||||
selectedOrder.value = order;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
selectedOrder.value = null;
|
||||
}
|
||||
|
||||
function reapply(order: DepositOrder) {
|
||||
const query: Record<string, string> = {
|
||||
orderId: order.id,
|
||||
methodType: order.methodType,
|
||||
amount: order.amount,
|
||||
};
|
||||
if (order.paymentMethodId) {
|
||||
query.methodId = order.paymentMethodId;
|
||||
}
|
||||
router.push({ path: '/wallet/recharge', query });
|
||||
router.push(`/wallet/recharge/history/${order.id}`);
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** Backend sets remark = rejectReason on reject; show one line only. */
|
||||
function orderNote(order: DepositOrder): { label: string; text: string } | null {
|
||||
function orderNoteLine(order: DepositOrder) {
|
||||
const rejectReason = normalizeText(order.rejectReason);
|
||||
const remark = normalizeText(order.remark);
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
const text = rejectReason || remark;
|
||||
if (!text) return null;
|
||||
return { label: t('recharge.reject_reason'), text };
|
||||
}
|
||||
|
||||
if (remark) {
|
||||
return { label: t('recharge.remark'), text: remark };
|
||||
return `${t('recharge.reject_reason')}: ${text}`;
|
||||
}
|
||||
|
||||
if (remark) return `${t('recharge.remark')}: ${remark}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderNoteLine(order: DepositOrder) {
|
||||
const note = orderNote(order);
|
||||
return note ? `${note.label}: ${note.text}` : null;
|
||||
}
|
||||
|
||||
function auditActionLabel(action: string) {
|
||||
const key = `recharge.audit_${action.toLowerCase()}` as const;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : action;
|
||||
}
|
||||
|
||||
function auditStepClass(action: string) {
|
||||
return `audit-step--${auditActionTone(action)}`;
|
||||
}
|
||||
|
||||
function formatAuditTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatOrderTime(iso: string) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function auditRemarkForTimeline(log: DepositAuditLog, order: DepositOrder) {
|
||||
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
|
||||
return null;
|
||||
}
|
||||
return formatDepositAuditRemark(log, t);
|
||||
}
|
||||
|
||||
function auditLogsForDisplay(order: DepositOrder) {
|
||||
return [...(order.auditLogs ?? [])]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map((log) => ({
|
||||
log,
|
||||
actor: auditActorSecondary(log, t),
|
||||
remark: auditRemarkForTimeline(log, order),
|
||||
}));
|
||||
}
|
||||
|
||||
function auditNoteLine(text: string) {
|
||||
return `${t('recharge.audit_remark_label')}: ${text}`;
|
||||
}
|
||||
|
||||
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
|
||||
if (remark.text === t('wallet.remark_deposit_revoke_generic')) {
|
||||
return remark.text;
|
||||
}
|
||||
return auditNoteLine(remark.text);
|
||||
}
|
||||
|
||||
function auditStepCount(order: DepositOrder) {
|
||||
return order.auditLogs?.length ?? 0;
|
||||
}
|
||||
@@ -268,6 +175,7 @@ function auditStepCount(order: DepositOrder) {
|
||||
<span :class="['status-badge', statusClass(order.status)]">{{ statusLabel(order.status) }}</span>
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<div class="order-no">{{ order.orderNo }}</div>
|
||||
<div class="order-amount">{{ formatMoney(order.amount, locale) }}</div>
|
||||
<div v-if="order.approvedAmount && order.approvedAmount !== order.amount" class="approved-amount">
|
||||
{{ t('recharge.credited') }}: {{ formatMoney(order.approvedAmount, locale) }}
|
||||
@@ -291,20 +199,12 @@ function auditStepCount(order: DepositOrder) {
|
||||
>
|
||||
{{ orderNoteLine(order) }}
|
||||
</div>
|
||||
<div v-if="auditStepCount(order)" class="card-detail-hint">
|
||||
<span class="card-detail-summary">
|
||||
<div class="card-detail-hint">
|
||||
<span v-if="auditStepCount(order)" class="card-detail-summary">
|
||||
{{ t('recharge.audit_summary', { count: auditStepCount(order) }) }}
|
||||
</span>
|
||||
<span class="card-detail-link">{{ t('recharge.view_detail') }} ›</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="card-detail-link-only"
|
||||
@click.stop="openDetail(order)"
|
||||
>
|
||||
{{ t('recharge.view_detail') }} ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,98 +219,6 @@ function auditStepCount(order: DepositOrder) {
|
||||
{{ t('common.no_more') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedOrder" class="detail-overlay" @click.self="closeDetail">
|
||||
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.audit_title')">
|
||||
<button type="button" class="detail-close" :aria-label="t('common.close')" @click="closeDetail">✕</button>
|
||||
|
||||
<h3 class="detail-title">{{ t('recharge.audit_title') }}</h3>
|
||||
|
||||
<div class="detail-summary">
|
||||
<div class="detail-summary-head">
|
||||
<span class="method-badge" :class="selectedOrder.methodType === 'BANK' ? 'bank' : 'usdt'">
|
||||
{{ selectedOrder.methodType }}
|
||||
</span>
|
||||
<span :class="['status-badge', statusClass(selectedOrder.status)]">
|
||||
{{ statusLabel(selectedOrder.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-amount">{{ formatMoney(selectedOrder.amount, locale) }}</div>
|
||||
<div
|
||||
v-if="selectedOrder.approvedAmount && selectedOrder.approvedAmount !== selectedOrder.amount"
|
||||
class="approved-amount"
|
||||
>
|
||||
{{ t('recharge.credited') }}: {{ formatMoney(selectedOrder.approvedAmount, locale) }}
|
||||
</div>
|
||||
<div class="detail-method-name">{{ selectedOrder.paymentMethodName || '-' }}</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.apply_time') }}</span>
|
||||
<span class="detail-value">{{ formatOrderTime(selectedOrder.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="selectedOrder.reviewedAt" class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.review_time') }}</span>
|
||||
<span class="detail-value">{{ formatOrderTime(selectedOrder.reviewedAt) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderNoteLine(selectedOrder)"
|
||||
:class="selectedOrder.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
|
||||
>
|
||||
{{ orderNoteLine(selectedOrder) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedOrder.auditLogs?.length" class="detail-audit">
|
||||
<h4 class="detail-audit-title">{{ t('recharge.audit_title') }}</h4>
|
||||
<div class="audit-track">
|
||||
<div
|
||||
v-for="(entry, logIdx) in auditLogsForDisplay(selectedOrder)"
|
||||
:key="entry.log.id"
|
||||
class="audit-step"
|
||||
:class="auditStepClass(entry.log.action)"
|
||||
>
|
||||
<div class="audit-step-rail" aria-hidden="true">
|
||||
<span class="audit-dot" />
|
||||
<span v-if="logIdx < auditLogsForDisplay(selectedOrder).length - 1" class="audit-line" />
|
||||
</div>
|
||||
<div class="audit-step-body">
|
||||
<div class="audit-step-head">
|
||||
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
|
||||
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
|
||||
<p
|
||||
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
|
||||
class="audit-step-credited"
|
||||
>
|
||||
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="entry.remark?.kind === 'reject'"
|
||||
class="audit-step-box audit-step-box--reject"
|
||||
>
|
||||
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
|
||||
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
|
||||
</div>
|
||||
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
|
||||
{{ auditNoteDisplayText(entry.remark) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="selectedOrder.status === 'REJECTED'"
|
||||
type="button"
|
||||
class="modal-reapply-btn btn-gold-outline"
|
||||
@click.stop="reapply(selectedOrder)"
|
||||
>
|
||||
{{ t('recharge.reapply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -435,9 +243,7 @@ function auditStepCount(order: DepositOrder) {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.order-card:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
.order-card:active { opacity: 0.92; }
|
||||
.order-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -453,14 +259,9 @@ function auditStepCount(order: DepositOrder) {
|
||||
background: linear-gradient(90deg, transparent, rgba(245, 108, 108, 0.35), transparent);
|
||||
}
|
||||
.order-card.rejected .order-amount {
|
||||
background: none;
|
||||
-webkit-background-clip: unset;
|
||||
background-clip: unset;
|
||||
color: #bbb;
|
||||
}
|
||||
.order-card.rejected .info-label {
|
||||
color: #888;
|
||||
}
|
||||
.order-card.rejected .info-label { color: #888; }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||
.method-badge.bank { background: rgba(30, 58, 95, 0.6); color: #66b1ff; }
|
||||
@@ -469,15 +270,18 @@ function auditStepCount(order: DepositOrder) {
|
||||
.status-pending { color: #e6a23c; }
|
||||
.status-approved { color: #67c23a; }
|
||||
.status-rejected { color: #f56c6c; }
|
||||
.order-body { }
|
||||
.order-amount {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 4px;
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.order-no {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, monospace;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.approved-amount { font-size: 12px; color: #67c23a; margin-bottom: 6px; font-weight: 600; }
|
||||
.order-info-row { margin-bottom: 8px; }
|
||||
@@ -520,39 +324,17 @@ function auditStepCount(order: DepositOrder) {
|
||||
}
|
||||
.card-detail-summary {
|
||||
font-size: 11px;
|
||||
color: rgba(212, 175, 55, 0.65);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-detail-link {
|
||||
font-size: 11px;
|
||||
color: var(--primary-light);
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-detail-link-only {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0 8px;
|
||||
}
|
||||
|
||||
.sentinel { height: 1px; }
|
||||
.load-more-spinner { display: flex; justify-content: center; padding: 20px 0 8px; }
|
||||
.end-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
@@ -561,291 +343,4 @@ function auditStepCount(order: DepositOrder) {
|
||||
padding: 16px 0 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: min(88vh, 720px);
|
||||
overflow-y: auto;
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-bottom: none;
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding: 16px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 28px 12px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.detail-summary-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-amount {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--primary-light, #d4af37);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.detail-method-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-summary .order-remark {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
padding: 6px 0 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-summary .reject-reason {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #b08888;
|
||||
background: transparent;
|
||||
padding: 6px 0 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(245, 108, 108, 0.15);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-summary .approved-amount {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-audit {
|
||||
margin-top: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.detail-audit-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.audit-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.audit-step {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.audit-step-rail {
|
||||
flex: 0 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.audit-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 10px;
|
||||
margin: 3px 0;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.audit-step-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.audit-step:last-child .audit-step-body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.audit-step-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-step-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.audit-step-time {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-step-actor {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.audit-step-credited {
|
||||
margin: 3px 0 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #7eb87a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.audit-step-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.audit-step-box {
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.audit-step-box--reject {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(245, 108, 108, 0.22);
|
||||
}
|
||||
|
||||
.audit-step-box-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #c07070;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.audit-step-box-text {
|
||||
font-size: 11px;
|
||||
color: #b08888;
|
||||
}
|
||||
|
||||
.audit-step--submitted .audit-dot {
|
||||
background: #c9a227;
|
||||
}
|
||||
.audit-step--approved .audit-dot {
|
||||
background: #5fad5a;
|
||||
}
|
||||
.audit-step--rejected .audit-dot {
|
||||
background: #d06060;
|
||||
}
|
||||
.audit-step--revoked .audit-dot {
|
||||
background: #777;
|
||||
}
|
||||
.audit-step--reopened .audit-dot {
|
||||
background: #c9a227;
|
||||
}
|
||||
|
||||
.modal-reapply-btn {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 11px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -681,7 +681,7 @@ onMounted(fetchMethods);
|
||||
.success-state { text-align: center; padding: 40px 16px; }
|
||||
.success-icon { font-size: 40px; color: #67c23a; margin-bottom: 10px; }
|
||||
.success-state h3 { margin: 0 0 6px; font-size: 16px; }
|
||||
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
|
||||
.order-no { font-family: monospace; color: var(--text-muted); font-size: 13px; margin: 4px 0; }
|
||||
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, tx
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import emptyWalletImg from '../assets/images/generated/empty-wallet-ledger-v1.webp';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -222,7 +223,8 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length && !initialLoading" class="empty">
|
||||
{{ t('wallet.no_records') }}
|
||||
<img :src="emptyWalletImg" alt="" class="empty-img" />
|
||||
<span>{{ t('wallet.no_records') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -383,5 +385,25 @@ const pullIndicatorStyle = () => ({
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 28px 16px 40px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: min(320px, 86vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
opacity: 0.9;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -262,23 +262,31 @@ function goCashbackDetail() {
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
background: #141414;
|
||||
border: 1px solid #222;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.07), transparent 34%),
|
||||
rgba(18, 17, 14, 0.92);
|
||||
border: 1px solid rgba(255, 226, 156, 0.12);
|
||||
}
|
||||
|
||||
.hero.pos {
|
||||
border-color: rgba(212, 175, 55, 0.25);
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.03));
|
||||
background:
|
||||
linear-gradient(135deg, rgba(212, 175, 55, 0.12), transparent 46%),
|
||||
rgba(18, 17, 14, 0.92);
|
||||
}
|
||||
|
||||
.hero.neg {
|
||||
border-color: rgba(224, 80, 80, 0.2);
|
||||
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
|
||||
background:
|
||||
linear-gradient(135deg, rgba(224, 80, 80, 0.12), transparent 46%),
|
||||
rgba(18, 14, 13, 0.92);
|
||||
}
|
||||
|
||||
.hero.zero {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: #141414;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 34%),
|
||||
rgba(18, 17, 14, 0.92);
|
||||
}
|
||||
|
||||
.hero-type {
|
||||
@@ -314,13 +322,22 @@ function goCashbackDetail() {
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #141414;
|
||||
border: 1px solid #222;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.04), transparent 28%),
|
||||
rgba(18, 17, 14, 0.9);
|
||||
border: 1px solid rgba(255, 226, 156, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -370,9 +387,9 @@ function goCashbackDetail() {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-gold-soft, rgba(212, 175, 55, 0.35));
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--primary-light);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../util
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import emptyWalletImg from '../assets/images/generated/empty-wallet-ledger-v1.webp';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -135,7 +136,8 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length" class="empty">
|
||||
{{ t('wallet.no_records') }}
|
||||
<img :src="emptyWalletImg" alt="" class="empty-img" />
|
||||
<span>{{ t('wallet.no_records') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -259,5 +261,25 @@ const pullIndicatorStyle = () => ({
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 28px 16px 40px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: min(320px, 86vw);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
opacity: 0.9;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
12
apps/player/src/views/RechargeDetailView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useViewport } from '../composables/useViewport';
|
||||
import MobileRechargeDetailView from './MobileRechargeDetailView.vue';
|
||||
import DesktopRechargeDetailView from './desktop/DesktopRechargeDetailView.vue';
|
||||
|
||||
const { isDesktop } = useViewport();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopRechargeDetailView v-if="isDesktop" />
|
||||
<MobileRechargeDetailView v-else />
|
||||
</template>
|
||||
@@ -11,6 +11,8 @@ import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import loginBg from '../assets/images/h5bg.webp';
|
||||
|
||||
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const auth = useAuthStore();
|
||||
@@ -101,11 +103,15 @@ const fieldError = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
|
||||
<div class="login-lang">
|
||||
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
|
||||
<div v-if="!props.desktop" class="login-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame" :class="{ 'is-busy': loading }">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="login-form"
|
||||
:class="{ 'ps-gold-frame': !props.desktop, 'is-busy': loading }"
|
||||
>
|
||||
<div v-if="loading" class="form-busy-overlay" aria-hidden="true">
|
||||
<GoldSpinner :size="40" :active="true" />
|
||||
<p class="form-busy-text">{{ t('auth.registering') }}</p>
|
||||
@@ -203,7 +209,7 @@ const fieldError = () => {
|
||||
{{ t('auth.have_account') }}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||
<button v-if="!props.desktop" type="button" class="btn-skip-light" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -233,6 +239,18 @@ const fieldError = () => {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ===== Desktop mode: fields only (card shell in DesktopAuthLayout) ===== */
|
||||
.login-page--desktop {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -243,6 +261,16 @@ const fieldError = () => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.login-page--desktop .login-form {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.login-form.is-busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -285,12 +313,24 @@ const fieldError = () => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-page--desktop .form-title {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
text-align: left;
|
||||
color: var(--primary-light, #f0d875);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-optional {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -302,22 +342,37 @@ label {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.login-page--desktop label {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.optional-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.login-page--desktop .optional-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
caret-color: var(--primary-light, #f0d875);
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
@@ -329,6 +384,42 @@ label {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Desktop: visible focus ring per UX guideline */
|
||||
.login-page--desktop .field-input {
|
||||
height: 46px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:focus {
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
color: var(--text);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.06), transparent 72%),
|
||||
#0d0d0d;
|
||||
background-color: #0d0d0d;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08),
|
||||
0 0 14px rgba(212, 175, 55, 0.14);
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:-webkit-autofill,
|
||||
.login-page--desktop .field-input:-webkit-autofill:hover,
|
||||
.login-page--desktop .field-input:-webkit-autofill:focus {
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
-webkit-text-fill-color: var(--text);
|
||||
box-shadow:
|
||||
0 0 0 1000px #0d0d0d inset,
|
||||
inset 0 0 18px rgba(212, 175, 55, 0.08);
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
|
||||
.login-page--desktop .field-input:hover:not(:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -352,6 +443,19 @@ label {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-secondary {
|
||||
height: 46px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-secondary:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
@@ -365,6 +469,28 @@ label {
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login {
|
||||
margin-top: 10px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:hover:not(:disabled) {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--border-gold, #D4AF37);
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
@@ -372,6 +498,10 @@ label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-login:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.btn-login.is-loading:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -385,12 +515,29 @@ label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-skip:active {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip {
|
||||
margin-top: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: rgba(20, 20, 20, 0.6);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .btn-skip:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.btn-skip-light {
|
||||
position: absolute;
|
||||
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
@@ -416,5 +563,30 @@ label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
animation: reg-fade-in 0.25s ease;
|
||||
}
|
||||
|
||||
.login-page--desktop .error {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
border-left: 2px solid var(--danger, #FF453A);
|
||||
}
|
||||
|
||||
@keyframes reg-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Respect reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -96,6 +96,24 @@ function translateSel(name: string): string {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -117,42 +135,70 @@ function translateSel(name: string): string {
|
||||
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail" class="detail-layout">
|
||||
<!-- Header info -->
|
||||
<div class="detail-card header-card">
|
||||
<div class="bet-no">{{ detail.betNo }}</div>
|
||||
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||
<div class="amount-row">
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.stake') || '投注额' }}</span>
|
||||
<span class="amount-val">{{ detail.stake }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
|
||||
<span class="amount-val gold">{{ detail.potentialReturn || '-' }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('bet.placed_at') || '下注时间' }}</span>
|
||||
<span class="amount-val date">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selections -->
|
||||
<div v-else-if="detail" class="detail-grid">
|
||||
<!-- Left column: selections -->
|
||||
<div class="detail-left">
|
||||
<div class="detail-card">
|
||||
<div class="card-title">{{ t('bet.selections') || '投注选项' }}</div>
|
||||
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-row">
|
||||
<div class="sel-row-left">
|
||||
<span class="sel-index">{{ idx + 1 }}</span>
|
||||
<div class="sel-info">
|
||||
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
|
||||
<div class="sel-meta">
|
||||
<span class="sel-market">{{ leg.marketLabel || '' }}</span>
|
||||
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
|
||||
<span v-if="leg.score?.ft" class="sel-score">{{ t('history.ft') }}: {{ leg.score.ft }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sel-row-right">
|
||||
<span class="sel-odds">@{{ leg.odds }}</span>
|
||||
<span v-if="leg.score?.ft" class="sel-score">({{ t('history.ft') }}: {{ leg.score.ft }})</span>
|
||||
<span v-if="leg.resultStatus" class="leg-result" :class="legResultClass(leg.resultStatus)">
|
||||
{{ legResultIcon(leg.resultStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: summary -->
|
||||
<div class="detail-right">
|
||||
<div class="detail-card summary-card">
|
||||
<div class="card-title">{{ t('bet.summary') || '注单概要' }}</div>
|
||||
<div class="summary-bet-no">
|
||||
<span class="summary-label">{{ t('bet.bet_no') || '注单号' }}</span>
|
||||
<span class="summary-value mono">{{ detail.betNo }}</span>
|
||||
</div>
|
||||
<div class="summary-status">
|
||||
<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>
|
||||
|
||||
<RouterLink to="/bets" class="back-to-list-btn">
|
||||
{{ t('bet.back_to_list') || '返回注单列表' }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -171,45 +217,127 @@ 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; }
|
||||
.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 {
|
||||
background: var(--desktop-sidebar-bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(rgba(22, 20, 15, 0.76), rgba(18, 16, 12, 0.82)),
|
||||
url('../../assets/images/generated/detail-ledger-ticket-bg-v1.webp') center / cover no-repeat;
|
||||
border: 1px solid var(--desktop-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-card { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.bet-no {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
.detail-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
|
||||
.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); }
|
||||
|
||||
/* Selection rows */
|
||||
.sel-row {
|
||||
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;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
|
||||
.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-default { background: rgba(100,100,100,0.1); color: #888; }
|
||||
|
||||
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
|
||||
.amount-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.amount-val.gold { color: var(--primary-light); }
|
||||
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||
.summary-amounts { display: flex; flex-direction: column; gap: 14px; padding-top: 16px; border-top: 1px solid var(--desktop-border); }
|
||||
.summary-amount-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.summary-amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.summary-amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.summary-amount-val.gold { color: var(--primary-light); }
|
||||
.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); }
|
||||
.sel-row:last-child { border-bottom: none; }
|
||||
.sel-match { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
||||
.sel-meta { display: flex; gap: 10px; align-items: center; 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; }
|
||||
@media (max-width: 900px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDesktopParlayMatches, type DesktopParlayMatch } from '../../composab
|
||||
import { useOutrightEvents } from '../../composables/useOutrightEvents';
|
||||
import MatchBetCard from '../../components/MatchBetCard.vue';
|
||||
import DesktopOutrightEventCard from '../../components/desktop/DesktopOutrightEventCard.vue';
|
||||
import emptyMatchesImg from '../../assets/images/generated/empty-matches-stadium-v1.webp';
|
||||
import {
|
||||
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
|
||||
isInLocalTodayMatchWindow as isInTodayMatchWindow,
|
||||
@@ -159,7 +160,7 @@ function closeModal() {
|
||||
|
||||
async function refresh() {
|
||||
if (mainTab.value === 'outright') {
|
||||
await loadOutrights({ silent: false });
|
||||
await loadOutrights({ silent: false, force: true });
|
||||
return;
|
||||
}
|
||||
await loadParlayMatches(true);
|
||||
@@ -209,18 +210,22 @@ void loadParlayMatches(true);
|
||||
<section v-for="lg in leagueGroups" :key="lg.leagueName" class="league-section">
|
||||
<h2 class="league-title">{{ lg.leagueName }}</h2>
|
||||
<div class="match-grid">
|
||||
<TransitionGroup name="card-list" appear tag="div" class="match-grid-inner">
|
||||
<MatchBetCard
|
||||
v-for="m in lg.matches"
|
||||
v-for="(m, i) in lg.matches"
|
||||
:key="m.id"
|
||||
class="match-card-dense"
|
||||
:style="{ '--i': i }"
|
||||
:match="m"
|
||||
@bet="goMatch"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-state-art" />
|
||||
<p>{{ t('bet.no_matches') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,6 +256,7 @@ void loadParlayMatches(true);
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-state-art" />
|
||||
<p>{{ outrightError || t('bet.no_outright') }}</p>
|
||||
<p v-if="!outrightError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
|
||||
</div>
|
||||
@@ -350,57 +356,116 @@ void loadParlayMatches(true);
|
||||
}
|
||||
|
||||
.match-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.match-grid-inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.match-card) {
|
||||
/* --- Card list entrance animation --- */
|
||||
.card-list-enter-active {
|
||||
animation: card-fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--i, 0) * 50ms);
|
||||
}
|
||||
|
||||
.card-list-leave-active {
|
||||
animation: card-fade-down 0.25s ease both;
|
||||
}
|
||||
|
||||
.card-list-move {
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
|
||||
@keyframes card-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-fade-down {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.match-grid-inner :deep(.match-card) {
|
||||
padding: 8px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.team-name) {
|
||||
.match-grid-inner :deep(.team-name) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.team-flag) {
|
||||
.match-grid-inner :deep(.team-flag) {
|
||||
width: 56px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.team-flag.flag-logo) {
|
||||
.match-grid-inner :deep(.team-flag.flag-logo) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.match-grid :deep(.kickoff) {
|
||||
.match-grid-inner :deep(.kickoff) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.vs) {
|
||||
.match-grid-inner :deep(.vs) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.bet-btn) {
|
||||
.match-grid-inner :deep(.bet-btn) {
|
||||
font-size: 11px;
|
||||
padding: 5px 10px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.match-grid :deep(.status-tag) {
|
||||
.match-grid-inner :deep(.status-tag) {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state-art {
|
||||
width: min(460px, 70%);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
opacity: 0.72;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
@@ -416,7 +481,8 @@ void loadParlayMatches(true);
|
||||
|
||||
.outright-event-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 420px));
|
||||
justify-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Desktop Forgot Password View
|
||||
*/
|
||||
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
|
||||
import ForgotPasswordView from '../ForgotPasswordView.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-auth-shell">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-inner">
|
||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||
<h1 class="brand-name">THEBET365</h1>
|
||||
<p class="brand-tagline">重置您的登录密码</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-card-wrap">
|
||||
<div class="auth-card">
|
||||
<ForgotPasswordView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopAuthLayout wide>
|
||||
<ForgotPasswordView desktop />
|
||||
</DesktopAuthLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-auth-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--desktop-bg);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
|
||||
border-right: 1px solid var(--desktop-border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-brand::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--primary-light);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-card-wrap {
|
||||
width: 480px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: var(--desktop-bg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,10 +8,10 @@ import AnnouncementMarquee from '../../components/AnnouncementMarquee.vue';
|
||||
import { usePlayerHome } from '../../composables/usePlayerHome';
|
||||
import TeamEmblem from '../../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import emptyMatchesImg from '../../assets/images/generated/empty-matches-stadium-v1.webp';
|
||||
import vsImg from '../../assets/images/generated/vs-gold-alpha-v1.webp';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import type { PlayerHomeMatch } from '../../composables/usePlayerHome';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
|
||||
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
|
||||
@@ -20,9 +20,7 @@ const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { openAt, visible: popoverVisible, pendingItem } = useDesktopBetPopover();
|
||||
const { openAt, visible: popoverVisible, pendingItem, cancelClose, scheduleClose, isActiveForCard } = useDesktopBetPopover();
|
||||
|
||||
const activeMarketTypes = ref<Record<string, string>>({});
|
||||
|
||||
@@ -111,10 +109,6 @@ function isSelected(id: string) {
|
||||
|
||||
function handleOddsClick(match: PlayerHomeMatch, market: any, selId: string, event?: MouseEvent) {
|
||||
if (isMarketLocked(match, market)) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt(router.currentRoute.value.fullPath);
|
||||
return;
|
||||
}
|
||||
const sel = market.selections?.find((s: any) => s.id === selId);
|
||||
if (!sel || !event) return;
|
||||
|
||||
@@ -140,6 +134,38 @@ function handleOddsClick(match: PlayerHomeMatch, market: any, selId: string, eve
|
||||
function getSelLabel(sel: any, marketType: string, lineValue?: string | number | null) {
|
||||
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
|
||||
}
|
||||
|
||||
function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: string) {
|
||||
if (!match || !popoverVisible.value || !pendingItem.value) return '';
|
||||
if (!isActiveForCard(cardRootId)) return '';
|
||||
if (String(pendingItem.value.matchId) !== String(match.id)) return '';
|
||||
|
||||
const selId = pendingItem.value.selectionId;
|
||||
// Scan all markets across all tabs to find the selection
|
||||
for (const market of match.markets ?? []) {
|
||||
const sel = market.selections?.find((s: any) => s.id === selId);
|
||||
if (!sel) continue;
|
||||
const code = (sel.selectionCode || '').toUpperCase();
|
||||
if (code === 'HOME') return 'home';
|
||||
if (code === 'AWAY') return 'away';
|
||||
if (code === 'DRAW') return 'draw';
|
||||
if (code === 'OVER') return 'home';
|
||||
if (code === 'UNDER') return 'away';
|
||||
if (code === 'ODD' || code === 'EVEN') return 'draw';
|
||||
// Fallback: use selection index
|
||||
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
|
||||
if (selIndex === -1) continue;
|
||||
if (market.marketType === 'FT_1X2') {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'draw';
|
||||
if (selIndex === 2) return 'away';
|
||||
} else {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'away';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -172,7 +198,15 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
|
||||
<template v-else-if="featuredMatch">
|
||||
<!-- 焦点大卡 -->
|
||||
<div class="featured-match anim-fade-up" style="--anim-delay:240ms" @click.self="goMatch(featuredMatch.id)">
|
||||
<div
|
||||
class="featured-match anim-fade-up"
|
||||
:class="{ 'featured-match--engaged': isActiveForCard(`featured-${featuredMatch.id}`) }"
|
||||
:data-bet-card-root="`featured-${featuredMatch.id}`"
|
||||
style="--anim-delay:240ms"
|
||||
@click.self="goMatch(featuredMatch.id)"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="featured-top" @click="goMatch(featuredMatch.id)">
|
||||
<div class="featured-meta">
|
||||
<span class="league-tag lg">{{ featuredMatch.leagueName }}</span>
|
||||
@@ -184,7 +218,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
<span class="featured-time">{{ formatKickoff(featuredMatch.startTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="featured-teams" @click="goMatch(featuredMatch.id)">
|
||||
<div class="featured-teams" :class="getHoveredSideForMatch(featuredMatch, `featured-${featuredMatch.id}`)" @click="goMatch(featuredMatch.id)">
|
||||
<div class="featured-team">
|
||||
<TeamEmblem
|
||||
size="lg"
|
||||
@@ -194,7 +228,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/>
|
||||
<span class="featured-team-name">{{ featuredMatch.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="featured-vs vs-heartbeat">VS</div>
|
||||
<div class="featured-vs vs-heartbeat">
|
||||
<img :src="vsImg" alt="VS" class="featured-vs-img" />
|
||||
</div>
|
||||
<div class="featured-team">
|
||||
<TeamEmblem
|
||||
size="lg"
|
||||
@@ -235,7 +271,11 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
v-for="(match, idx) in restHotMatches"
|
||||
:key="match.id"
|
||||
class="match-card anim-fade-up"
|
||||
:class="{ 'match-card--engaged': isActiveForCard(`hot-${match.id}`) }"
|
||||
:data-bet-card-root="`hot-${match.id}`"
|
||||
:style="{ '--anim-delay': (320 + idx * 60) + 'ms' }"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="match-info-side" @click="goMatch(match.id)">
|
||||
<!-- Default info layout (visible at rest) -->
|
||||
@@ -257,7 +297,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/>
|
||||
<span class="team-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="vs-text">VS</div>
|
||||
<div class="vs-text">
|
||||
<img :src="vsImg" alt="VS" class="vs-mini-img" />
|
||||
</div>
|
||||
<div class="team-row">
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
@@ -271,7 +313,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
</div>
|
||||
|
||||
<!-- Hover versus layout (visible on hover) -->
|
||||
<div class="hover-versus-layout">
|
||||
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `hot-${match.id}`)">
|
||||
<div class="hover-team home-slide">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
@@ -281,7 +323,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/>
|
||||
<span class="hover-team-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="hover-vs-glow">VS</div>
|
||||
<div class="hover-vs-glow">
|
||||
<img :src="vsImg" alt="VS" class="hover-vs-img" />
|
||||
</div>
|
||||
<div class="hover-team away-slide">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
@@ -328,6 +372,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-state-art" />
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -344,7 +389,11 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
v-for="(match, idx) in upcomingList"
|
||||
:key="match.id"
|
||||
class="match-card anim-fade-up"
|
||||
:class="{ 'match-card--engaged': isActiveForCard(`upcoming-${match.id}`) }"
|
||||
:data-bet-card-root="`upcoming-${match.id}`"
|
||||
:style="{ '--anim-delay': (260 + idx * 50) + 'ms' }"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="match-info-side" @click="goMatch(match.id)">
|
||||
<!-- Default info layout (visible at rest) -->
|
||||
@@ -365,7 +414,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/>
|
||||
<span class="team-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="vs-text">VS</div>
|
||||
<div class="vs-text">
|
||||
<img :src="vsImg" alt="VS" class="vs-mini-img" />
|
||||
</div>
|
||||
<div class="team-row">
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
@@ -379,7 +430,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
</div>
|
||||
|
||||
<!-- Hover versus layout (visible on hover) -->
|
||||
<div class="hover-versus-layout">
|
||||
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `upcoming-${match.id}`)">
|
||||
<div class="hover-team home-slide">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
@@ -389,7 +440,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/>
|
||||
<span class="hover-team-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="hover-vs-glow">VS</div>
|
||||
<div class="hover-vs-glow">
|
||||
<img :src="vsImg" alt="VS" class="hover-vs-img" />
|
||||
</div>
|
||||
<div class="hover-team away-slide">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
@@ -434,6 +487,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-state-art" />
|
||||
<p>{{ t('home.upcoming_empty') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -452,8 +506,12 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
v-for="(m, idx) in sideRecommendMatches"
|
||||
:key="m.id"
|
||||
class="side-recommend-item anim-fade-up"
|
||||
:class="{ 'side-recommend-item--engaged': isActiveForCard(`sidebar-${m.id}`) }"
|
||||
:data-bet-card-root="`sidebar-${m.id}`"
|
||||
:style="{ '--anim-delay': (200 + idx * 70) + 'ms' }"
|
||||
@click="goMatch(m.id)"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="sri-top-row">
|
||||
<span class="sri-league">{{ m.leagueName }}</span>
|
||||
@@ -461,22 +519,26 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
</div>
|
||||
<div class="sri-teams">
|
||||
<span>{{ m.homeTeamName }}</span>
|
||||
<span class="sri-vs">vs</span>
|
||||
<span class="sri-vs">
|
||||
<img :src="vsImg" alt="VS" class="sri-vs-img" />
|
||||
</span>
|
||||
<span>{{ m.awayTeamName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮预览卡 -->
|
||||
<div class="sri-preview-card" @click.stop>
|
||||
<div class="sri-preview-card" @click.stop @mouseenter="cancelClose" @mouseleave="scheduleClose()">
|
||||
<div class="sri-preview-top">
|
||||
<span class="sri-preview-league">{{ m.leagueName }}</span>
|
||||
<span class="sri-preview-time">{{ formatKickoff(m.startTime) }}</span>
|
||||
</div>
|
||||
<div class="sri-preview-vs">
|
||||
<div class="sri-preview-vs" :class="getHoveredSideForMatch(m, `sidebar-${m.id}`)">
|
||||
<div class="sri-preview-team home-team">
|
||||
<TeamEmblem size="md" :team-code="m.homeTeamCode" :team-name="m.homeTeamName" :logo-url="m.homeTeamLogoUrl" />
|
||||
<span>{{ m.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="sri-preview-vs-text">VS</div>
|
||||
<div class="sri-preview-vs-text">
|
||||
<img :src="vsImg" alt="VS" class="sri-preview-vs-img" />
|
||||
</div>
|
||||
<div class="sri-preview-team away-team">
|
||||
<TeamEmblem size="md" :team-code="m.awayTeamCode" :team-name="m.awayTeamName" :logo-url="m.awayTeamLogoUrl" />
|
||||
<span>{{ m.awayTeamName }}</span>
|
||||
@@ -508,7 +570,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
.desktop-home-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -537,7 +599,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
.home-marquee-strip {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(212, 175, 55, 0.02),
|
||||
@@ -545,9 +607,11 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
rgba(212, 175, 55, 0.02));
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
align-items: stretch; /* let child fill full height */
|
||||
padding: 0; /* no padding — child spans edge to edge */
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
box-shadow: inset 0 0 12px rgba(212, 175, 55, 0.08);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
}
|
||||
|
||||
/* force the embedded button to fill the strip completely */
|
||||
@@ -565,7 +629,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
.main-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -573,22 +637,25 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
background: rgba(32, 30, 24, 0.78);
|
||||
backdrop-filter: var(--blur-md);
|
||||
-webkit-backdrop-filter: var(--blur-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) 18px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* section-title 金线 */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.st-line {
|
||||
@@ -600,8 +667,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.st-text {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 400;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -635,43 +703,88 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 160px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-art {
|
||||
width: min(420px, 72%);
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
opacity: 0.72;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10%, #000 90%, transparent 100%);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Featured 焦点大卡 */
|
||||
.featured-match {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
gap: var(--space-4);
|
||||
padding: 18px 20px 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.04) 50%, rgba(15, 10, 0, 0.1)),
|
||||
url('../../assets/images/card-bg.webp') center/cover no-repeat;
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.34), rgba(0, 0, 0, 0.12) 50%, rgba(10, 6, 0, 0.36)),
|
||||
url('../../assets/images/generated/pc-featured-match-bg-v1.webp') center / 100% 100% no-repeat;
|
||||
border: 1px solid var(--border-gold);
|
||||
box-shadow: var(--shadow-gold), inset 0 0 20px rgba(212, 175, 55, 0.06);
|
||||
margin-bottom: 14px;
|
||||
box-shadow: var(--shadow-gold-lg), inset 0 0 20px rgba(212, 175, 55, 0.06);
|
||||
margin-bottom: var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.25s, transform 0.15s;
|
||||
transition: box-shadow var(--duration-smooth) var(--ease-smooth),
|
||||
transform var(--duration-normal) var(--ease-out-expo);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-match > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.featured-match::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
width: 140px; height: 140px;
|
||||
background: radial-gradient(circle at top right, rgba(212, 175, 55, 0.18), transparent 65%);
|
||||
width: 180px; height: 180px;
|
||||
background: radial-gradient(circle at top right, rgba(212, 175, 55, 0.2), transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
animation: goldGlowPulse 3.5s ease-in-out infinite;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.featured-match::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: url('../../assets/images/generated/pc-featured-match-bg-v1.webp') center / 100% 100% no-repeat;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) top / 100% 22px no-repeat,
|
||||
linear-gradient(#000 0 0) bottom / 100% 22px no-repeat,
|
||||
linear-gradient(#000 0 0) left / 22px 100% no-repeat,
|
||||
linear-gradient(#000 0 0) right / 22px 100% no-repeat;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) top / 100% 22px no-repeat,
|
||||
linear-gradient(#000 0 0) bottom / 100% 22px no-repeat,
|
||||
linear-gradient(#000 0 0) left / 22px 100% no-repeat,
|
||||
linear-gradient(#000 0 0) right / 22px 100% no-repeat;
|
||||
}
|
||||
|
||||
.featured-match:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 24px rgba(212, 175, 55, 0.25), inset 0 0 20px rgba(212, 175, 55, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--glow-gold-lg), inset 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.featured-top {
|
||||
@@ -733,21 +846,35 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.featured-vs {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
color: var(--primary-light);
|
||||
text-shadow: 0 0 12px rgba(212, 175, 55, 0.5);
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.featured-vs-img {
|
||||
width: 68px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 0 4px rgba(255, 231, 150, 0.42))
|
||||
drop-shadow(0 0 8px rgba(212, 175, 55, 0.26));
|
||||
}
|
||||
|
||||
.featured-markets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin: 14px -20px -18px;
|
||||
padding: 14px 20px 18px;
|
||||
background: #000000;
|
||||
border-top: 1px solid rgba(212, 175, 55, 0.2);
|
||||
margin: 14px -12px 0;
|
||||
padding: 14px 12px 8px;
|
||||
background: rgba(10, 8, 4, 0.46);
|
||||
border-top: 1px solid rgba(246, 210, 108, 0.22);
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 236, 174, 0.08);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.featured-market-block {
|
||||
@@ -758,8 +885,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.featured-market-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -776,32 +904,38 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
.match-card {
|
||||
display: flex;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
transition: border-color var(--duration-normal) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth),
|
||||
transform var(--duration-normal) var(--ease-out-expo);
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.match-card:hover {
|
||||
.match-card:is(:hover, .match-card--engaged) {
|
||||
border-color: var(--border-gold);
|
||||
box-shadow: var(--shadow-gold);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-gold-lg), var(--glow-gold);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.match-info-side {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
border-right: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background var(--duration-fast) var(--ease-smooth),
|
||||
box-shadow var(--duration-normal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.match-info-side > * {
|
||||
@@ -809,8 +943,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.match-card:hover .match-info-side {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
.match-card:is(:hover, .match-card--engaged) .match-info-side {
|
||||
background: rgba(212, 175, 55, 0.03);
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
.match-market-side {
|
||||
@@ -840,12 +975,13 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.league-tag {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
background: var(--border-gold-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -857,20 +993,23 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
font-weight: 400;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.match-time {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.teams-versus-compact {
|
||||
@@ -881,15 +1020,20 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.vs-text {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 36px;
|
||||
line-height: 1;
|
||||
margin: -2px 0;
|
||||
}
|
||||
|
||||
.vs-mini-img {
|
||||
width: 30px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
opacity: 0.78;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.75));
|
||||
}
|
||||
|
||||
.team-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -898,7 +1042,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
@@ -916,14 +1060,16 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
|
||||
.market-tab-btn {
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: background var(--duration-fast) var(--ease-smooth),
|
||||
color var(--duration-fast) var(--ease-smooth);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -937,6 +1083,11 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.market-tab-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.market-odds-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -946,7 +1097,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.no-market-odds {
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -956,18 +1107,21 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.home-sidebar :deep(.home-announce-card) {
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.side-card {
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
background: rgba(20, 20, 20, 0.88);
|
||||
backdrop-filter: var(--blur-md);
|
||||
-webkit-backdrop-filter: var(--blur-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.side-card-header {
|
||||
@@ -979,8 +1133,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.side-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 400;
|
||||
color: var(--primary-light);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
@@ -998,14 +1153,18 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition: background var(--duration-fast) var(--ease-smooth),
|
||||
transform var(--duration-fast) var(--ease-out-expo),
|
||||
box-shadow var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.side-recommend-item:hover {
|
||||
background: var(--bg-hover);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
transform: translateX(3px);
|
||||
box-shadow: inset 2px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
.sri-top-row {
|
||||
@@ -1043,10 +1202,17 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.sri-vs {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sri-vs-img {
|
||||
width: 22px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
opacity: 0.7;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
||||
.sri-time {
|
||||
@@ -1109,10 +1275,6 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
}
|
||||
|
||||
.featured-match::before {
|
||||
animation: goldGlowPulse 3.5s ease-in-out infinite;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
/* --- 3. VS 心跳 --- */
|
||||
@keyframes vsHeartbeat {
|
||||
@@ -1155,7 +1317,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
transition: opacity 0.35s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.match-card:hover .default-info-layout {
|
||||
.match-card:is(:hover, .match-card--engaged) .default-info-layout {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
pointer-events: none;
|
||||
@@ -1180,7 +1342,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.match-card:hover .hover-versus-layout {
|
||||
.match-card:is(:hover, .match-card--engaged) .hover-versus-layout {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
@@ -1198,7 +1360,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.match-card:hover .hover-team.home-slide {
|
||||
.match-card:is(:hover, .match-card--engaged) .hover-team.home-slide {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1215,7 +1377,7 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.match-card:hover .hover-team.away-slide {
|
||||
.match-card:is(:hover, .match-card--engaged) .hover-team.away-slide {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1232,20 +1394,27 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.hover-vs-glow {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
color: var(--primary-light);
|
||||
width: 58px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(2.8);
|
||||
opacity: 0;
|
||||
text-shadow:
|
||||
0 0 10px rgba(212, 175, 55, 0.8),
|
||||
0 0 20px rgba(212, 175, 55, 0.4);
|
||||
transition: transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s, opacity 0.3s ease 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.match-card:hover .hover-vs-glow {
|
||||
.hover-vs-img {
|
||||
width: 56px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 0 5px rgba(255, 231, 150, 0.52))
|
||||
drop-shadow(0 0 9px rgba(212, 175, 55, 0.34));
|
||||
}
|
||||
|
||||
.match-card:is(:hover, .match-card--engaged) .hover-vs-glow {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
animation: vs-clash-impact 0.3s ease-out 0.35s;
|
||||
@@ -1253,11 +1422,9 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
|
||||
@keyframes vs-clash-impact {
|
||||
0% {
|
||||
text-shadow: 0 0 25px rgba(212, 175, 55, 1);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
100% {
|
||||
text-shadow: 0 0 8px rgba(212, 175, 55, 0.7);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1275,10 +1442,11 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
/* --- 8. 推荐列表 item 左侧金点指示 hover --- */
|
||||
.side-recommend-item {
|
||||
position: relative;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
transition: background 0.18s, transform 0.18s;
|
||||
}
|
||||
|
||||
.side-recommend-item:hover {
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
@@ -1302,20 +1470,23 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
transform: translateY(-50%) translateX(8px);
|
||||
width: 260px;
|
||||
background: rgba(12, 12, 12, 0.97);
|
||||
backdrop-filter: var(--blur-lg);
|
||||
-webkit-backdrop-filter: var(--blur-lg);
|
||||
border: 1px solid var(--border-gold);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 20px rgba(212, 175, 55, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) 14px;
|
||||
box-shadow: var(--shadow-lg), var(--glow-gold-md);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
z-index: 100;
|
||||
transition: opacity var(--duration-smooth) var(--ease-smooth),
|
||||
transform var(--duration-smooth) var(--ease-out-expo);
|
||||
z-index: var(--z-popover);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.side-recommend-item:hover .sri-preview-card {
|
||||
.side-recommend-item:is(:hover, .side-recommend-item--engaged) .sri-preview-card {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
pointer-events: auto;
|
||||
@@ -1390,17 +1561,26 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
}
|
||||
|
||||
.sri-preview-vs-text {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
color: var(--primary-light);
|
||||
text-shadow: 0 0 8px rgba(212, 175, 55, 0.6);
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transform: scale(2.2);
|
||||
transition: opacity 0.3s ease 0.1s, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s;
|
||||
}
|
||||
|
||||
.sri-preview-vs-img {
|
||||
width: 46px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 0 4px rgba(255, 231, 150, 0.42))
|
||||
drop-shadow(0 0 8px rgba(212, 175, 55, 0.26));
|
||||
}
|
||||
|
||||
.side-recommend-item:hover .sri-preview-vs-text {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -1418,21 +1598,19 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: url('../../assets/images/generated/odds-button-normal-v1.webp') center / 100% 100% no-repeat;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.sri-preview-odd-btn:hover {
|
||||
background: var(--border-gold-soft);
|
||||
border-color: var(--border-gold);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.sri-preview-odd-btn.selected {
|
||||
background: var(--border-gold-soft);
|
||||
border-color: var(--primary-light);
|
||||
background-image: url('../../assets/images/generated/odds-button-selected-v1.webp');
|
||||
}
|
||||
|
||||
.sri-odd-label {
|
||||
@@ -1453,4 +1631,96 @@ function getSelLabel(sel: any, marketType: string, lineValue?: string | number |
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Sri preview: team highlight on odds selection --- */
|
||||
.sri-preview-team {
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
|
||||
.sri-preview-vs.home .sri-preview-team.home-team {
|
||||
transform: translateX(0) scale(1.1) !important;
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.sri-preview-vs.home .sri-preview-team.away-team {
|
||||
opacity: 0.35 !important;
|
||||
transform: translateX(0) scale(0.92) !important;
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.sri-preview-vs.away .sri-preview-team.away-team {
|
||||
transform: translateX(0) scale(1.1) !important;
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.sri-preview-vs.away .sri-preview-team.home-team {
|
||||
opacity: 0.35 !important;
|
||||
transform: translateX(0) scale(0.92) !important;
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.sri-preview-vs.draw .sri-preview-team {
|
||||
transform: translateX(0) scale(1.05) !important;
|
||||
filter: brightness(1.15) saturate(1.1);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* --- VS Selection Highlight: scale selected team, no VS animation --- */
|
||||
.featured-team,
|
||||
.hover-team {
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* featured match (home) */
|
||||
.featured-teams.home .featured-team:first-child {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
}
|
||||
.featured-teams.home .featured-team:last-child {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
/* featured match (away) */
|
||||
.featured-teams.away .featured-team:last-child {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
}
|
||||
.featured-teams.away .featured-team:first-child {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
/* featured match (draw) */
|
||||
.featured-teams.draw .featured-team {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.15) saturate(1.1);
|
||||
}
|
||||
|
||||
/* hover-versus-layout (sidebar/upcoming) */
|
||||
.hover-versus-layout.home .hover-team.home-slide {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
z-index: 2;
|
||||
}
|
||||
.hover-versus-layout.home .hover-team.away-slide {
|
||||
opacity: 0.35 !important;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
.hover-versus-layout.away .hover-team.away-slide {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
z-index: 2;
|
||||
}
|
||||
.hover-versus-layout.away .hover-team.home-slide {
|
||||
opacity: 0.35 !important;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
.hover-versus-layout.draw .hover-team.home-slide,
|
||||
.hover-versus-layout.draw .hover-team.away-slide {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.15) saturate(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -150,7 +150,7 @@ onActivated(refreshUnreadCount);
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--desktop-border);
|
||||
background: var(--desktop-sidebar-bg);
|
||||
background: var(--desktop-sidebar-panel-bg);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Desktop Login View
|
||||
* Reuses the existing LoginView form in a centered two-column auth layout.
|
||||
*/
|
||||
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
|
||||
import LoginView from '../LoginView.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-auth-shell">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-inner">
|
||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||
<h1 class="brand-name">THEBET365</h1>
|
||||
<p class="brand-tagline">专业体育赛事投注平台</p>
|
||||
<div class="brand-features">
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">⚡</span>
|
||||
<span>实时赔率更新</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">🔒</span>
|
||||
<span>安全稳定平台</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">💰</span>
|
||||
<span>快速充值提款</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-card-wrap">
|
||||
<div class="auth-card">
|
||||
<LoginView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopAuthLayout>
|
||||
<LoginView desktop />
|
||||
</DesktopAuthLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-auth-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--desktop-bg);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
|
||||
border-right: 1px solid var(--desktop-border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-brand::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--primary-light);
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(212,175,55,0.25);
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.auth-card-wrap {
|
||||
width: 480px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: var(--desktop-bg);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import TeamEmblem from '../../components/TeamEmblem.vue';
|
||||
import MatchBetGuide from '../../components/match-detail/MatchBetGuide.vue';
|
||||
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
|
||||
@@ -17,13 +16,13 @@ import { formatLocalMatchDateTime, defaultMarketName, DETAIL_MARKET_TYPES } from
|
||||
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
|
||||
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
|
||||
import vsImg from '../../assets/images/generated/vs-gold-alpha-v1.webp';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const { openAt, visible: popoverVisible, pendingItem: popoverItem } = useDesktopBetPopover();
|
||||
const { openAt, visible: popoverVisible, pendingItem: popoverItem, cancelClose, scheduleClose } = useDesktopBetPopover();
|
||||
const { pendingLocate, clearLocate } = useDesktopBetLocate();
|
||||
|
||||
function goBack() {
|
||||
@@ -291,14 +290,41 @@ function buildSlipItem(sel: Selection, market: Market) {
|
||||
|
||||
function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
if (!match.value || isMarketLocked(market)) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt(route.fullPath);
|
||||
return;
|
||||
}
|
||||
const item = buildSlipItem(sel, market);
|
||||
if (!item || !event) return;
|
||||
openAt(event, item);
|
||||
}
|
||||
|
||||
function getHoveredSide() {
|
||||
if (!match.value || !popoverVisible.value || !popoverItem.value) return '';
|
||||
if (String(popoverItem.value.matchId) !== String(match.value.id)) return '';
|
||||
|
||||
const selId = popoverItem.value.selectionId;
|
||||
// Scan all markets to find the selection by selectionCode
|
||||
for (const market of allMarkets.value) {
|
||||
const sel = market.selections?.find((s: any) => s.id === selId);
|
||||
if (!sel) continue;
|
||||
const code = (sel.selectionCode || '').toUpperCase();
|
||||
if (code === 'HOME') return 'home';
|
||||
if (code === 'AWAY') return 'away';
|
||||
if (code === 'DRAW') return 'draw';
|
||||
if (code === 'OVER') return 'home';
|
||||
if (code === 'UNDER') return 'away';
|
||||
if (code === 'ODD' || code === 'EVEN') return 'draw';
|
||||
// Fallback: use selection index
|
||||
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
|
||||
if (selIndex === -1) continue;
|
||||
if (market.marketType === 'FT_1X2') {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'draw';
|
||||
if (selIndex === 2) return 'away';
|
||||
} else {
|
||||
if (selIndex === 0) return 'home';
|
||||
if (selIndex === 1) return 'away';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -318,7 +344,12 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
|
||||
<template v-else-if="match">
|
||||
<!-- High Density Score Band -->
|
||||
<div class="match-score-band" :class="{ 'phase-settled': matchPhase === 'settled' }">
|
||||
<div
|
||||
class="match-score-band"
|
||||
:class="[{ 'phase-settled': matchPhase === 'settled' }, getHoveredSide()]"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="team-side home">
|
||||
<TeamEmblem size="xl" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
|
||||
<span class="name">{{ match.homeTeamName }}</span>
|
||||
@@ -330,7 +361,9 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
<span class="divider">-</span>
|
||||
<span class="score">{{ match.score?.ftAway }}</span>
|
||||
</div>
|
||||
<div class="versus-row vs" v-else>VS</div>
|
||||
<div class="versus-row vs" v-else>
|
||||
<img :src="vsImg" alt="VS" class="score-vs-img" />
|
||||
</div>
|
||||
<span class="kickoff-time">{{ kickoff }}</span>
|
||||
</div>
|
||||
|
||||
@@ -382,6 +415,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
:key="market.id"
|
||||
class="market-card"
|
||||
:class="{ locked: isMarketLocked(market) }"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="market-hdr">
|
||||
<span class="market-title">{{ marketLabel(market) }}</span>
|
||||
@@ -410,6 +445,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
:key="market.id"
|
||||
class="score-block"
|
||||
:class="{ locked: isMarketLocked(market) }"
|
||||
@mouseenter="cancelClose"
|
||||
@mouseleave="scheduleClose()"
|
||||
>
|
||||
<div class="score-block-hdr">
|
||||
<span>{{ marketLabel(market) }}</span>
|
||||
@@ -503,10 +540,16 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
}
|
||||
|
||||
.match-score-band {
|
||||
background: linear-gradient(180deg, #181818 0%, #111 100%);
|
||||
border: 1px solid var(--border);
|
||||
aspect-ratio: 1600 / 320;
|
||||
background-image: url('../../assets/images/generated/pc-featured-match-bg-v1.webp');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
border: 0;
|
||||
box-shadow: var(--shadow-gold);
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
padding: 12px 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -557,9 +600,17 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
}
|
||||
|
||||
.versus-row.vs {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
min-height: 42px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-vs-img {
|
||||
width: 72px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 0 4px rgba(255, 231, 150, 0.42))
|
||||
drop-shadow(0 0 8px rgba(212, 175, 55, 0.26));
|
||||
}
|
||||
|
||||
.kickoff-time {
|
||||
@@ -768,4 +819,34 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- VS Selection Highlight --- */
|
||||
.team-side {
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
|
||||
.match-score-band.home .team-side.home {
|
||||
transform: scale(1.06);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
}
|
||||
.match-score-band.home .team-side.away {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.match-score-band.away .team-side.away {
|
||||
transform: scale(1.06);
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
}
|
||||
.match-score-band.away .team-side.home {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.94);
|
||||
filter: grayscale(0.6) brightness(0.7);
|
||||
}
|
||||
|
||||
.match-score-band.draw .team-side {
|
||||
transform: scale(1.04);
|
||||
filter: brightness(1.15) saturate(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopBetDetailModal from '../../components/desktop/DesktopBetDetailModal.vue';
|
||||
import { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
@@ -12,6 +12,14 @@ const COL_COUNT = 8;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
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 total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -149,10 +157,13 @@ function statusLabel(status: string) {
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else-if="items.length">
|
||||
<tr v-for="bet in items" :key="bet.betNo" class="hover-row">
|
||||
<td>
|
||||
<RouterLink :to="`/bets/${bet.betNo}`" class="link-cell">{{ bet.betNo }}</RouterLink>
|
||||
</td>
|
||||
<tr
|
||||
v-for="bet in items"
|
||||
:key="bet.betNo"
|
||||
class="hover-row clickable-row"
|
||||
@click="openDetail(bet.betNo)"
|
||||
>
|
||||
<td class="id-cell">{{ bet.betNo }}</td>
|
||||
<td>{{ bet.matchTitle || '-' }}</td>
|
||||
<td class="muted-cell">{{ bet.pickLabel || '-' }}</td>
|
||||
<td class="num-cell">{{ bet.totalOdds ?? '-' }}</td>
|
||||
@@ -183,12 +194,16 @@ function statusLabel(status: string) {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopBetDetailModal v-model:visible="detailModalVisible" :bet-no="detailBetNo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.muted-cell {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
max-width: 280px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,11 +8,11 @@ import OutrightOptionCard from '../../components/outright/OutrightOptionCard.vue
|
||||
import OutrightBetModal, { type OutrightPick } from '../../components/outright/OutrightBetModal.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import saishiImg from '../../assets/images/saishi.webp';
|
||||
import cardBg from '../../assets/images/card-bg.webp';
|
||||
import saishiImg from '../../assets/images/generated/saishi-gold-trophy-v1.webp';
|
||||
import outrightDetailBg from '../../assets/images/generated/pc-outright-detail-bg-v1.webp';
|
||||
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const matchCardBg = `url(${outrightDetailBg})`;
|
||||
const ODDS_POLL_MS = 15000;
|
||||
|
||||
const route = useRoute();
|
||||
@@ -246,7 +246,7 @@ onUnmounted(stopPolling);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / cover no-repeat;
|
||||
opacity: 0.22;
|
||||
opacity: 0.36;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ const auth = useAuthStore();
|
||||
const { loadProfile, setAvatarKey, profileRaw, avatarUrl } = usePlayerProfile();
|
||||
|
||||
const username = ref('');
|
||||
const viewablePassword = ref('');
|
||||
const passwordVisible = ref(false);
|
||||
const phone = ref('');
|
||||
const email = ref('');
|
||||
const avatarModalOpen = ref(false);
|
||||
@@ -35,11 +33,6 @@ const allowUsernameChange = computed(
|
||||
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
|
||||
);
|
||||
|
||||
const canTogglePassword = computed(() => !!viewablePassword.value);
|
||||
const passwordInputType = computed(() =>
|
||||
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
|
||||
);
|
||||
|
||||
const displayAvatarUrl = computed(() => {
|
||||
if (avatarUrl.value) return avatarUrl.value;
|
||||
const seed = profileRaw.value?.username ?? auth.user?.username;
|
||||
@@ -49,7 +42,6 @@ const displayAvatarUrl = computed(() => {
|
||||
function syncFromProfile() {
|
||||
const user = profileRaw.value;
|
||||
username.value = user?.username ?? auth.user?.username ?? '';
|
||||
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
|
||||
phone.value = user?.preferences?.phone ?? '';
|
||||
email.value = user?.preferences?.email ?? '';
|
||||
}
|
||||
@@ -98,23 +90,26 @@ async function savePassword() {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAvatarSelect(key: string | null) {
|
||||
avatarModalOpen.value = false;
|
||||
await setAvatarKey(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-account-page">
|
||||
<main class="account-main">
|
||||
<div class="edit-layout">
|
||||
<div class="page-header">
|
||||
<div class="desktop-records-page edit-page">
|
||||
<header class="desktop-records-header">
|
||||
<div class="desktop-records-header-main header-row">
|
||||
<button type="button" class="back-btn" @click="router.push('/profile')">
|
||||
<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>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
<span>{{ t('common.back') || '返回' }}</span>
|
||||
</button>
|
||||
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Avatar section -->
|
||||
<div class="edit-card">
|
||||
<div class="edit-grid">
|
||||
<!-- Left: avatar + basic -->
|
||||
<section class="edit-card">
|
||||
<div class="card-title">{{ t('profile.avatar') || '头像' }}</div>
|
||||
<div class="avatar-row">
|
||||
<img
|
||||
@@ -126,20 +121,22 @@ async function onAvatarSelect(key: string | null) {
|
||||
<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>
|
||||
<div class="avatar-meta">
|
||||
<button type="button" class="change-avatar-btn" @click="avatarModalOpen = true">
|
||||
{{ t('profile.change_avatar') || '更换头像' }}
|
||||
</button>
|
||||
<p class="field-hint">{{ t('profile.avatar_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Basic info -->
|
||||
<div class="edit-card">
|
||||
<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-group">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label class="field-label">{{ t('profile.username') || '用户名' }}</label>
|
||||
<input
|
||||
@@ -157,19 +154,21 @@ async function onAvatarSelect(key: string | null) {
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Email</label>
|
||||
<label class="field-label">{{ t('profile.email') || '邮箱' }}</label>
|
||||
<input v-model="email" type="email" class="field-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button type="button" class="save-btn" :disabled="saving" @click="saveProfile">
|
||||
<GoldSpinner v-if="saving" :size="18" />
|
||||
<span v-else>{{ t('common.save') || '保存' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password change -->
|
||||
<div v-if="allowPasswordChange" class="edit-card">
|
||||
<!-- Password -->
|
||||
<section v-if="allowPasswordChange" class="edit-card edit-card--span">
|
||||
<div class="card-title">
|
||||
{{ t('profile.change_password') || '修改密码' }}
|
||||
<button
|
||||
@@ -181,7 +180,7 @@ async function onAvatarSelect(key: string | null) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="passwordChangeOpen" class="field-group">
|
||||
<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" />
|
||||
@@ -194,12 +193,14 @@ async function onAvatarSelect(key: string | null) {
|
||||
<label class="field-label">{{ t('profile.confirm_password') || '确认密码' }}</label>
|
||||
<input v-model="confirmPassword" type="password" class="field-input" autocomplete="new-password" />
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<PlayerAvatarModal
|
||||
@@ -208,33 +209,88 @@ async function onAvatarSelect(key: string | null) {
|
||||
@close="avatarModalOpen = false"
|
||||
@confirm="(key) => { avatarModalOpen = false; if (key !== undefined) setAvatarKey(key); }"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
||||
|
||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
|
||||
|
||||
.edit-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 560px;
|
||||
.edit-page {
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.back-btn:hover svg {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.back-btn svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.edit-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.edit-card--span {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.edit-grid > .edit-card:nth-of-type(2) {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.edit-grid > .edit-card:nth-of-type(3) {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -251,21 +307,30 @@ async function onAvatarSelect(key: string | null) {
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.avatar-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border-gold-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
@@ -273,9 +338,12 @@ async function onAvatarSelect(key: string | null) {
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
border: 2px solid var(--desktop-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.change-avatar-btn {
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
@@ -287,11 +355,32 @@ async function onAvatarSelect(key: string | null) {
|
||||
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(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.edit-grid > .edit-card:nth-of-type(2) .field:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.edit-grid > .edit-card:nth-of-type(3) .field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
@@ -313,13 +402,45 @@ async function onAvatarSelect(key: string | null) {
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.field-input:focus { border-color: var(--border-gold-soft); }
|
||||
.field-input:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.field-input:focus {
|
||||
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; }
|
||||
.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; }
|
||||
.field-hint {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -337,8 +458,14 @@ async function onAvatarSelect(key: string | null) {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) { opacity: 0.88; }
|
||||
.save-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.save-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: none;
|
||||
@@ -350,4 +477,26 @@ async function onAvatarSelect(key: string | null) {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.edit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.edit-grid > .edit-card:nth-of-type(2),
|
||||
.edit-grid > .edit-card:nth-of-type(3) {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import LocaleFlag from '../../components/LocaleFlag.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useAppLocale } from '../../composables/useAppLocale';
|
||||
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
||||
import { useInboxFeature } from '../../composables/useInboxFeature';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
||||
import walletBg from '../../assets/images/wallet-bg.webp';
|
||||
import walletBg from '../../assets/images/generated/pc-wallet-banner-v1.webp';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
@@ -69,15 +67,40 @@ const balanceDisplay = computed(() =>
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="desktop-account-page">
|
||||
<main class="account-main">
|
||||
<div class="profile-layout">
|
||||
<div class="page-header">
|
||||
<div class="desktop-records-page profile-page">
|
||||
<header class="desktop-records-header">
|
||||
<div class="desktop-records-header-main">
|
||||
<h1 class="page-title">{{ t('nav.profile') }}</h1>
|
||||
</div>
|
||||
<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" />
|
||||
@@ -89,7 +112,7 @@ const balanceDisplay = computed(() =>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Wallet Banner -->
|
||||
<!-- Wallet card -->
|
||||
<div class="wallet-banner">
|
||||
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
||||
<div class="wallet-banner-scrim" aria-hidden="true" />
|
||||
@@ -103,53 +126,61 @@ const balanceDisplay = computed(() =>
|
||||
<span class="bank-card-type">MEMBER</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/wallet/recharge" class="recharge-btn">
|
||||
<RouterLink to="/wallet/recharge" class="recharge-btn">
|
||||
<span class="recharge-icon">+</span>
|
||||
<span>{{ t('recharge.title') }}</span>
|
||||
</router-link>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="bank-card-balance">
|
||||
<span class="bank-card-label">{{ t('wallet.available') || '可用余额' }}</span>
|
||||
<p class="bank-card-number">{{ balanceDisplay }}</p>
|
||||
<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-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-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-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>
|
||||
<!-- 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>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profile.phone') || '手机号' }}</span>
|
||||
<span class="info-val">{{ profileRaw?.preferences?.phone || '-' }}</span>
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>{{ t('profile.username') }}</dt>
|
||||
<dd>{{ profileRaw?.username }}</dd>
|
||||
</div>
|
||||
<div class="info-actions">
|
||||
<router-link to="/profile/edit" class="edit-btn">{{ t('profile.edit') }}</router-link>
|
||||
<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>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="lang-card">
|
||||
<div class="info-header">{{ t('profile.language') }}</div>
|
||||
<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"
|
||||
@@ -159,47 +190,108 @@ const balanceDisplay = computed(() =>
|
||||
:class="{ active: locale === item.code }"
|
||||
@click="setLocale(item.code)"
|
||||
>
|
||||
<LocaleFlag :locale="item.code" :size="14" />
|
||||
<LocaleFlag :locale="item.code" :size="16" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button type="button" class="logout-btn" @click="logout">
|
||||
{{ t('auth.logout') }}
|
||||
</button>
|
||||
</template>
|
||||
<!-- Quick links: desktop density -->
|
||||
<section class="panel quick-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{{ t('profile.quick_nav') }}</h2>
|
||||
</div>
|
||||
</main>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
||||
|
||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.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;
|
||||
.profile-page {
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.profile-page::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 21 / 9;
|
||||
border-radius: 16px;
|
||||
max-height: 220px;
|
||||
border-radius: 14px;
|
||||
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 {
|
||||
@@ -219,8 +311,9 @@ const balanceDisplay = computed(() =>
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
transform: scale(1.05);
|
||||
filter: brightness(0.86);
|
||||
object-position: center center;
|
||||
transform: scale(1.01);
|
||||
filter: brightness(0.94);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -230,7 +323,12 @@ const balanceDisplay = computed(() =>
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
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 {
|
||||
@@ -239,30 +337,37 @@ const balanceDisplay = computed(() =>
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
padding: 20px 28px 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bank-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bank-card-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bank-card-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
@@ -270,17 +375,32 @@ const balanceDisplay = computed(() =>
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.bank-card-type {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(212, 175, 55, 0.8);
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -291,42 +411,59 @@ const balanceDisplay = computed(() =>
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
backdrop-filter: blur(6px);
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bank-card-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
min-height: 0;
|
||||
padding: 4px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bank-card-number {
|
||||
margin: 0;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -334,6 +471,7 @@ const balanceDisplay = computed(() =>
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bank-card-field--center { text-align: center; }
|
||||
@@ -344,67 +482,110 @@ const balanceDisplay = computed(() =>
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
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 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
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);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
padding: 18px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--desktop-border);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
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;
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: transparent;
|
||||
@@ -415,25 +596,72 @@ const balanceDisplay = computed(() =>
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.lang-opt:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lang-opt.active {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logout-btn:hover { background: rgba(255,69,58,0.06); }
|
||||
.quick-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
438
apps/player/src/views/desktop/DesktopRechargeDetailView.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import {
|
||||
auditActionTone,
|
||||
auditActorSecondary,
|
||||
formatDepositAuditRemark,
|
||||
shouldShowAuditRejectInTimeline,
|
||||
} from '../../utils/depositAuditDisplay';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
interface DepositAuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
statusBefore: string | null;
|
||||
statusAfter: string;
|
||||
amount: string | null;
|
||||
approvedAmount: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type RechargeDetail = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
paymentMethodId?: string;
|
||||
methodType: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
approvedAmount: string | null;
|
||||
rejectReason: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
reviewedAt: string | null;
|
||||
paymentMethodName: string | null;
|
||||
auditLogs?: DepositAuditLog[];
|
||||
};
|
||||
|
||||
const detail = ref<RechargeDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
function goBack() {
|
||||
if (window.history.state && window.history.state.back) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/wallet/recharge/history');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = route.params.id as string;
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const { data } = await api.get(`/player/deposit-orders/${id}`);
|
||||
detail.value = data.data ?? null;
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail);
|
||||
|
||||
function statusClass(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
APPROVED: 'status-won',
|
||||
REJECTED: 'status-lost',
|
||||
PENDING: 'status-pending',
|
||||
};
|
||||
return map[status?.toUpperCase()] ?? 'status-default';
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const s = status?.toUpperCase();
|
||||
if (s === 'APPROVED') return t('recharge.status_approved');
|
||||
if (s === 'REJECTED') return t('recharge.status_rejected');
|
||||
return t('recharge.status_pending');
|
||||
}
|
||||
|
||||
function methodLabel(order: RechargeDetail) {
|
||||
if (order.methodType === 'USDT') return 'USDT';
|
||||
return order.paymentMethodName || order.methodType || '-';
|
||||
}
|
||||
|
||||
function reapply(order: RechargeDetail) {
|
||||
const query: Record<string, string> = {
|
||||
orderId: order.id,
|
||||
methodType: order.methodType,
|
||||
amount: order.amount,
|
||||
};
|
||||
if (order.paymentMethodId) query.methodId = order.paymentMethodId;
|
||||
router.push({ path: '/wallet/recharge', query });
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function orderNote(order: RechargeDetail): { label: string; text: string } | null {
|
||||
const rejectReason = normalizeText(order.rejectReason);
|
||||
const remark = normalizeText(order.remark);
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
const text = rejectReason || remark;
|
||||
if (!text) return null;
|
||||
return { label: t('recharge.reject_reason'), text };
|
||||
}
|
||||
|
||||
if (remark) return { label: t('recharge.remark'), text: remark };
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderNoteLine(order: RechargeDetail) {
|
||||
const note = orderNote(order);
|
||||
return note ? `${note.label}: ${note.text}` : null;
|
||||
}
|
||||
|
||||
function auditActionLabel(action: string) {
|
||||
const key = `recharge.audit_${action.toLowerCase()}` as const;
|
||||
const translated = t(key);
|
||||
return translated !== key ? translated : action;
|
||||
}
|
||||
|
||||
function auditStepClass(action: string) {
|
||||
return `audit-step--${auditActionTone(action)}`;
|
||||
}
|
||||
|
||||
function formatAuditTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function auditRemarkForTimeline(log: DepositAuditLog, order: RechargeDetail) {
|
||||
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
|
||||
return null;
|
||||
}
|
||||
return formatDepositAuditRemark(log, t);
|
||||
}
|
||||
|
||||
function auditLogsForDisplay(order: RechargeDetail) {
|
||||
return [...(order.auditLogs ?? [])]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map((log) => ({
|
||||
log,
|
||||
actor: auditActorSecondary(log, t),
|
||||
remark: auditRemarkForTimeline(log, order),
|
||||
}));
|
||||
}
|
||||
|
||||
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
|
||||
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
|
||||
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-account-page">
|
||||
<main class="account-main">
|
||||
<div class="page-header">
|
||||
<button type="button" class="back-btn" @click="goBack">
|
||||
‹ {{ t('common.back') || '返回' }}
|
||||
</button>
|
||||
<h1 class="page-title">{{ t('recharge.order_detail') }}</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="loadDetail">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail" class="detail-layout">
|
||||
<div class="detail-card header-card">
|
||||
<div class="order-no">{{ detail.orderNo }}</div>
|
||||
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
|
||||
<div class="amount-row">
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('wallet.amount') }}</span>
|
||||
<span class="amount-val">{{ formatMoney(detail.amount, locale) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
|
||||
class="amount-item"
|
||||
>
|
||||
<span class="amount-label">{{ t('recharge.credited') }}</span>
|
||||
<span class="amount-val">{{ formatMoney(detail.approvedAmount, locale) }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.method') }}</span>
|
||||
<span class="amount-val method">{{ methodLabel(detail) }}</span>
|
||||
</div>
|
||||
<div class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.apply_time') }}</span>
|
||||
<span class="amount-val date">{{ new Date(detail.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detail.reviewedAt" class="amount-item">
|
||||
<span class="amount-label">{{ t('recharge.review_time') }}</span>
|
||||
<span class="amount-val date">{{ new Date(detail.reviewedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderNoteLine(detail)"
|
||||
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
|
||||
>
|
||||
{{ orderNoteLine(detail) }}
|
||||
</div>
|
||||
<button
|
||||
v-if="detail.status === 'REJECTED'"
|
||||
type="button"
|
||||
class="reapply-btn"
|
||||
@click="reapply(detail)"
|
||||
>
|
||||
{{ t('recharge.reapply') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.auditLogs?.length" class="detail-card">
|
||||
<div class="card-title">{{ t('recharge.audit_title') }}</div>
|
||||
<div class="audit-track">
|
||||
<div
|
||||
v-for="(entry, logIdx) in auditLogsForDisplay(detail)"
|
||||
:key="entry.log.id"
|
||||
class="audit-step"
|
||||
:class="auditStepClass(entry.log.action)"
|
||||
>
|
||||
<div class="audit-step-rail" aria-hidden="true">
|
||||
<span class="audit-dot" />
|
||||
<span v-if="logIdx < auditLogsForDisplay(detail).length - 1" class="audit-line" />
|
||||
</div>
|
||||
<div class="audit-step-body">
|
||||
<div class="audit-step-head">
|
||||
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
|
||||
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
|
||||
<p
|
||||
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
|
||||
class="audit-step-credited"
|
||||
>
|
||||
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
|
||||
</p>
|
||||
<div v-if="entry.remark?.kind === 'reject'" class="audit-step-box audit-step-box--reject">
|
||||
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
|
||||
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
|
||||
</div>
|
||||
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
|
||||
{{ auditNoteDisplayText(entry.remark) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
||||
|
||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
|
||||
.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:hover { color: var(--text); }
|
||||
.page-title { font-size: 18px; font-weight: 800; color: var(--text); 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; }
|
||||
.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-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(rgba(22, 20, 15, 0.76), rgba(18, 16, 12, 0.82)),
|
||||
url('../../assets/images/generated/detail-ledger-ticket-bg-v1.webp') center / cover no-repeat;
|
||||
border: 1px solid var(--desktop-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-card { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.order-no {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
|
||||
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
|
||||
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
|
||||
.status-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
|
||||
.status-default { background: rgba(100,100,100,0.1); color: #888; }
|
||||
|
||||
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
|
||||
.amount-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.amount-val.method { font-size: 14px; font-weight: 700; }
|
||||
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
.reject-reason {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 69, 58, 0.22);
|
||||
font-size: 13px;
|
||||
color: #ff8a82;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.order-remark {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.reapply-btn {
|
||||
align-self: flex-start;
|
||||
padding: 9px 24px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.reapply-btn:hover { opacity: 0.88; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.audit-track { display: flex; flex-direction: column; }
|
||||
|
||||
.audit-step { display: flex; gap: 10px; }
|
||||
|
||||
.audit-step-rail {
|
||||
flex: 0 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.audit-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 12px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.audit-step-body { flex: 1; min-width: 0; padding-bottom: 14px; }
|
||||
.audit-step:last-child .audit-step-body { padding-bottom: 0; }
|
||||
|
||||
.audit-step-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-step-title { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.audit-step-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.audit-step-actor { margin: 3px 0 0; font-size: 12px; color: var(--text-muted); }
|
||||
.audit-step-credited { margin: 4px 0 0; font-size: 12px; font-weight: 600; color: #7eb87a; }
|
||||
.audit-step-note { margin: 4px 0 0; font-size: 12px; color: var(--text-muted); line-height: 1.45; word-break: break-word; }
|
||||
|
||||
.audit-step-box {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.audit-step-box--reject { border: 1px solid rgba(255, 69, 58, 0.22); }
|
||||
.audit-step-box-label { font-size: 10px; font-weight: 600; color: #c07070; }
|
||||
.audit-step-box-text { font-size: 12px; color: #b08888; }
|
||||
|
||||
.audit-step--submitted .audit-dot { background: var(--primary); }
|
||||
.audit-step--approved .audit-dot { background: #4cd964; }
|
||||
.audit-step--rejected .audit-dot { background: #ff453a; }
|
||||
.audit-step--revoked .audit-dot { background: #888; }
|
||||
.audit-step--reopened .audit-dot { background: #c9a227; }
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
@@ -11,6 +11,7 @@ import { formatMoney } from '../../utils/localeDisplay';
|
||||
const COL_COUNT = 5;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
type RechargeOrder = {
|
||||
id: string;
|
||||
@@ -20,7 +21,6 @@ type RechargeOrder = {
|
||||
methodType?: string;
|
||||
bankName?: string | null;
|
||||
paymentMethodName?: string | null;
|
||||
approvedAmount?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -105,8 +105,13 @@ function statusLabel(status: string) {
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else-if="items.length">
|
||||
<tr v-for="order in items" :key="order.id" class="hover-row">
|
||||
<td class="order-no">{{ order.orderNo || order.id }}</td>
|
||||
<tr
|
||||
v-for="order in items"
|
||||
:key="order.id"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/wallet/recharge/history/${order.id}`)"
|
||||
>
|
||||
<td class="id-cell">{{ order.orderNo || order.id }}</td>
|
||||
<td>{{ methodLabel(order) }}</td>
|
||||
<td class="num-cell">{{ formatMoney(order.amount, locale) }}</td>
|
||||
<td>
|
||||
@@ -136,10 +141,3 @@ function statusLabel(status: string) {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-no {
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,13 @@ import imageCompression from 'browser-image-compression';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { useDepositNotifications } from '../../composables/useDepositNotifications';
|
||||
import { useAppToast } from '../../composables/useAppToast';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { trackPendingOrder } = useDepositNotifications();
|
||||
const { showToast } = useAppToast();
|
||||
|
||||
const reapplyOrderId = computed(() => {
|
||||
const id = route.query.orderId;
|
||||
@@ -45,6 +47,12 @@ const bankMethods = computed(() => methods.value.filter((m) => m.methodType ===
|
||||
const usdtMethods = computed(() => methods.value.filter((m) => m.methodType === 'USDT'));
|
||||
const currentMethods = computed(() => methodType.value === 'BANK' ? bankMethods.value : usdtMethods.value);
|
||||
|
||||
const copyText = (text: string) => {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
showToast(t('common.copy_success') || '已复制');
|
||||
};
|
||||
|
||||
function applyReapplyQuery() {
|
||||
const type = route.query.methodType;
|
||||
if (type === 'BANK' || type === 'USDT') methodType.value = type;
|
||||
@@ -107,8 +115,13 @@ async function submit() {
|
||||
fd.append('paymentMethodId', selectedMethod.value.id);
|
||||
fd.append('amount', amount.value);
|
||||
fd.append('screenshot', screenshotFile.value);
|
||||
if (isReapply.value) fd.append('originalOrderId', reapplyOrderId.value);
|
||||
const { data } = await api.post('/player/deposits', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
|
||||
let url = '/player/deposit-orders';
|
||||
if (isReapply.value) {
|
||||
url = `/player/deposit-orders/${reapplyOrderId.value}/reapply`;
|
||||
}
|
||||
|
||||
const { data } = await api.post(url, fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
orderNo.value = data.data?.orderNo ?? '';
|
||||
if (data.data?.id) trackPendingOrder(String(data.data.id));
|
||||
success.value = true;
|
||||
@@ -128,6 +141,13 @@ onActivated(fetchMethods);
|
||||
<main class="account-main">
|
||||
<div class="recharge-layout">
|
||||
<div class="page-header">
|
||||
<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">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
<span>{{ t('common.back') || '返回' }}</span>
|
||||
</button>
|
||||
<h1 class="page-title">{{ t('recharge.title') }}</h1>
|
||||
</div>
|
||||
|
||||
@@ -136,16 +156,30 @@ onActivated(fetchMethods);
|
||||
</div>
|
||||
|
||||
<div v-else-if="success" class="success-card">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-5"/></svg>
|
||||
<h2>{{ t('recharge.success_title') }}</h2>
|
||||
<p class="order-no">{{ t('recharge.order_no') }}: {{ orderNo }}</p>
|
||||
<p class="success-hint">{{ t('recharge.success_hint') }}</p>
|
||||
<div class="success-icon-wrap">
|
||||
<svg class="check-svg" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#D4AF37" stroke-width="3" />
|
||||
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#D4AF37" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{{ t('recharge.success_title') || '充值申请已提交' }}</h2>
|
||||
<div class="success-details">
|
||||
<div class="success-detail-row">
|
||||
<span class="s-label">{{ t('recharge.order_no') || '订单号' }}</span>
|
||||
<span class="s-value mono muted">{{ orderNo }}</span>
|
||||
</div>
|
||||
<div class="success-detail-row">
|
||||
<span class="s-label">{{ t('recharge.amount') || '充值金额' }}</span>
|
||||
<span class="s-value gold">¥ {{ parseFloat(amount).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="success-hint">{{ t('recharge.success_hint') || '您的充值申请已提交,客服将尽快为您处理。' }}</p>
|
||||
<button type="button" class="action-btn" @click="router.push('/wallet/recharge/history')">
|
||||
{{ t('recharge.history_title') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-else class="recharge-box">
|
||||
<!-- Method type tabs -->
|
||||
<div class="type-tabs">
|
||||
<button type="button" class="type-tab" :class="{ active: methodType === 'BANK' }" @click="switchType('BANK')">
|
||||
@@ -171,7 +205,13 @@ onActivated(fetchMethods);
|
||||
:class="{ selected: selectedMethod?.id === m.id }"
|
||||
@click="selectMethod(m)"
|
||||
>
|
||||
<div class="method-card-header">
|
||||
<div class="method-icon-wrap">
|
||||
<svg v-if="m.methodType === 'BANK'" viewBox="0 0 24 24" class="method-svg-icon"><path fill="currentColor" d="M22 6H2c-1.1 0-1.99.9-1.99 2L0 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H2v-6h20v6zm0-8H2V8h20v2z"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" class="method-svg-icon"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1.5 13H10.5v-1H9.06c-.53 0-.96-.43-.96-.96V10.5c0-.53.43-.96.96-.96h3.88c.53 0 .96.43.96.96v2.54c0 .53-.43.96-.96.96H10.5v1h3v-1.5z"/></svg>
|
||||
</div>
|
||||
<span class="method-name">{{ m.displayName || m.bankName || 'USDT' }}</span>
|
||||
</div>
|
||||
<span class="method-account">{{ m.accountNumber || m.usdtAddress || '' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -183,29 +223,46 @@ onActivated(fetchMethods);
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.bank_name') || '银行名称' }}</span>
|
||||
<span class="detail-val">{{ selectedMethod.bankName }}</span>
|
||||
<button type="button" class="copy-btn" @click="copyText(selectedMethod.bankName || '')">
|
||||
{{ t('common.copy') || '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.account_holder') || '持卡人' }}</span>
|
||||
<span class="detail-val">{{ selectedMethod.accountHolder }}</span>
|
||||
<button type="button" class="copy-btn" @click="copyText(selectedMethod.accountHolder || '')">
|
||||
{{ t('common.copy') || '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.account_number') || '账号' }}</span>
|
||||
<span class="detail-val mono">{{ selectedMethod.accountNumber }}</span>
|
||||
<button type="button" class="copy-btn" @click="copyText(selectedMethod.accountNumber || '')">
|
||||
{{ t('common.copy') || '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USDT QR -->
|
||||
<div v-if="methodType === 'USDT' && selectedMethod.qrCodeUrl" class="qr-wrap">
|
||||
<div v-if="methodType === 'USDT' && selectedMethod.qrCodeUrl" class="qr-section">
|
||||
<div class="qr-wrap">
|
||||
<img :src="selectedMethod.qrCodeUrl" alt="USDT QR" class="qr-img" />
|
||||
</div>
|
||||
<p class="qr-hint">{{ t('recharge.scan_to_pay') || '请扫描二维码完成支付' }}</p>
|
||||
</div>
|
||||
<div v-if="methodType === 'USDT'" class="detail-row">
|
||||
<span class="detail-label">{{ t('recharge.usdt_address') || 'USDT地址' }}</span>
|
||||
<span class="detail-val mono break-all">{{ selectedMethod.usdtAddress }}</span>
|
||||
<button type="button" class="copy-btn" @click="copyText(selectedMethod.usdtAddress || '')">
|
||||
{{ t('common.copy') || '复制' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Amount input -->
|
||||
<div class="field">
|
||||
<label class="field-label">{{ t('recharge.amount') || '充值金额' }}</label>
|
||||
<div class="amount-input-wrap">
|
||||
<span class="currency-symbol">¥</span>
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
@@ -214,6 +271,18 @@ onActivated(fetchMethods);
|
||||
:placeholder="t('recharge.amount_placeholder') || '请输入金额'"
|
||||
/>
|
||||
</div>
|
||||
<div class="amount-presets">
|
||||
<button
|
||||
v-for="val in [100, 500, 1000, 5000, 10000]"
|
||||
:key="val"
|
||||
type="button"
|
||||
class="preset-btn"
|
||||
@click="amount = String(val)"
|
||||
>
|
||||
+{{ val }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot upload -->
|
||||
<div class="field">
|
||||
@@ -243,20 +312,18 @@ onActivated(fetchMethods);
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
||||
|
||||
.recharge-layout {
|
||||
.desktop-account-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -267,12 +334,52 @@ onActivated(fetchMethods);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recharge-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.back-btn svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover svg {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
margin: 0;
|
||||
@@ -286,40 +393,94 @@ onActivated(fetchMethods);
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
/* Success Card */
|
||||
.success-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
background: rgba(25, 25, 25, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.12);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
margin: 40px auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.success-card h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
margin: 0;
|
||||
.success-icon-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
.check-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.check-circle {
|
||||
stroke-dasharray: 151;
|
||||
stroke-dashoffset: 151;
|
||||
animation: circle-draw 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
stroke-dasharray: 42;
|
||||
stroke-dashoffset: 42;
|
||||
animation: check-draw 0.35s 0.35s ease forwards;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.success-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.s-label {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.s-value {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success-hint {
|
||||
font-size: 13px;
|
||||
.s-value.muted {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.s-value.gold {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.success-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 9px 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: #111;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
|
||||
color: #3d2800;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
@@ -327,41 +488,66 @@ onActivated(fetchMethods);
|
||||
transition: opacity 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.action-btn:hover { opacity: 0.88; }
|
||||
.action-btn:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
/* Recharge Box Card */
|
||||
.recharge-box {
|
||||
background: rgba(25, 25, 25, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Segment Tabs */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
margin-bottom: 24px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
padding: 6px 20px;
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-tab:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.type-tab.active {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
background: rgba(212,175,55,0.08);
|
||||
color: #3d2800;
|
||||
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
|
||||
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
/* Columns */
|
||||
.recharge-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 24px;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@@ -370,7 +556,7 @@ onActivated(fetchMethods);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.no-methods {
|
||||
@@ -387,35 +573,71 @@ onActivated(fetchMethods);
|
||||
.method-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.method-card:hover { border-color: rgba(255, 255, 255, 0.15); }
|
||||
.method-card:hover {
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.method-card.selected {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
}
|
||||
|
||||
.method-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.method-icon-wrap {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.method-svg-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.method-card.selected .method-icon-wrap {
|
||||
background: var(--primary);
|
||||
color: #3d2800;
|
||||
}
|
||||
|
||||
.method-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.method-account {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Form & Detail Card */
|
||||
.recharge-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -423,57 +645,96 @@ onActivated(fetchMethods);
|
||||
}
|
||||
|
||||
.bank-detail-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 80px;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.detail-val {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mono { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.04em; }
|
||||
.break-all { word-break: break-all; }
|
||||
.copy-btn {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
border-radius: 4px;
|
||||
color: var(--primary-light);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* USDT QR */
|
||||
.qr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Inputs & Zones */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -488,11 +749,33 @@ onActivated(fetchMethods);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.amount-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.amount-input-wrap .field-input {
|
||||
padding-left: 28px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
background: #111;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -501,7 +784,33 @@ onActivated(fetchMethods);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.field-input:focus { border-color: var(--border-gold-soft); }
|
||||
.field-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.amount-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
border-color: rgba(212, 175, 55, 0.3);
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.05);
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
position: relative;
|
||||
@@ -512,7 +821,7 @@ onActivated(fetchMethods);
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--desktop-border);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
@@ -542,15 +851,21 @@ onActivated(fetchMethods);
|
||||
gap: 8px;
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
border: 2px dashed var(--desktop-border);
|
||||
border: 1.5px dashed rgba(212, 175, 55, 0.3);
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover { border-color: var(--border-gold-soft); }
|
||||
.upload-zone:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
}
|
||||
|
||||
.hidden-input { display: none; }
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
@@ -563,18 +878,35 @@ onActivated(fetchMethods);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 11px 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: #111;
|
||||
padding: 12px 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
|
||||
color: #3d2800;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) { opacity: 0.88; }
|
||||
.submit-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes circle-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes check-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Desktop Register View
|
||||
* Reuses the existing RegisterView form in a centered desktop auth layout.
|
||||
*/
|
||||
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
|
||||
import RegisterView from '../RegisterView.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-auth-shell">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-inner">
|
||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||
<h1 class="brand-name">THEBET365</h1>
|
||||
<p class="brand-tagline">加入我们,开始精彩体育投注</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-card-wrap">
|
||||
<div class="auth-card">
|
||||
<RegisterView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopAuthLayout wide>
|
||||
<RegisterView desktop />
|
||||
</DesktopAuthLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-auth-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--desktop-bg);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
|
||||
border-right: 1px solid var(--desktop-border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-brand::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--primary-light);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-card-wrap {
|
||||
width: 520px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: var(--desktop-bg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,8 +93,7 @@ onActivated(() => loadPage(1));
|
||||
<tr
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId"
|
||||
class="hover-row"
|
||||
style="cursor:pointer"
|
||||
class="hover-row clickable-row"
|
||||
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
|
||||
>
|
||||
<td class="type-cell">{{ txLabel(tx) }}</td>
|
||||
@@ -125,7 +124,7 @@ onActivated(() => loadPage(1));
|
||||
<style scoped>
|
||||
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
|
||||
|
||||
.account-main { flex: 1; min-width: 0; padding: 24px 28px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.account-main { flex: 1; min-width: 0; padding: var(--space-6) var(--space-7); display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.records-layout {
|
||||
display: flex;
|
||||
@@ -137,57 +136,64 @@ onActivated(() => loadPage(1));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
|
||||
.back-btn { background: none; border: none; color: var(--text-muted); font-size: 14px; font-weight: 700; cursor: pointer; padding: 0; }
|
||||
.page-header { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||
.back-btn { background: none; border: none; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-sm); font-weight: 700; cursor: pointer; padding: 0; transition: color var(--duration-fast) var(--ease-smooth); }
|
||||
.back-btn:hover { color: var(--text); }
|
||||
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
|
||||
.back-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--radius); }
|
||||
.page-title { font-family: var(--font-display); font-size: var(--text-xl); font-weight: 400; color: var(--primary-light); margin: 0; }
|
||||
.loading-state { display: flex; justify-content: center; align-items: center; padding: 80px 0; }
|
||||
.table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(18, 18, 18, 0.92);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.table-wrap { flex: 1; overflow-x: auto; overflow-y: auto; }
|
||||
.dt { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.dt th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
z-index: var(--z-sticky);
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
background: #141414;
|
||||
background: rgba(12, 12, 12, 0.97);
|
||||
backdrop-filter: var(--blur-sm);
|
||||
-webkit-backdrop-filter: var(--blur-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--desktop-border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dt th:first-child { border-top-left-radius: 8px; }
|
||||
.dt th:last-child { border-top-right-radius: 8px; }
|
||||
.dt th:first-child { border-top-left-radius: var(--radius-lg); }
|
||||
.dt th:last-child { border-top-right-radius: var(--radius-lg); }
|
||||
.num-th { text-align: right; }
|
||||
.dt td { padding: 11px 12px; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text); vertical-align: middle; }
|
||||
.hover-row { transition: background 0.15s; }
|
||||
.hover-row:hover { background: rgba(255,255,255,0.03); }
|
||||
.type-cell { font-weight: 700; }
|
||||
.note-cell { color: var(--text-muted); font-size: 12px; max-width: 220px; }
|
||||
.num-cell { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
|
||||
.date-cell { white-space: nowrap; color: var(--text-muted); font-size: 12px; }
|
||||
.dt td { padding: 11px 12px; border-bottom: 1px solid var(--border-subtle); color: var(--text); vertical-align: middle; }
|
||||
.hover-row { transition: background var(--duration-fast) var(--ease-smooth), box-shadow var(--duration-fast) var(--ease-smooth); }
|
||||
.hover-row:hover { background: rgba(212, 175, 55, 0.04); box-shadow: inset 3px 0 0 var(--primary); }
|
||||
.type-cell { font-family: var(--font-body); font-weight: 700; }
|
||||
.note-cell { color: var(--text-muted); font-size: var(--text-sm); max-width: 220px; }
|
||||
.num-cell { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--font-mono); font-weight: 700; }
|
||||
.date-cell { white-space: nowrap; color: var(--text-muted); font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
.arrow-cell { color: #555; font-size: 18px; font-weight: 300; text-align: right; width: 24px; }
|
||||
.pos { color: var(--primary-light); }
|
||||
.neg { color: var(--danger); }
|
||||
.zero { color: var(--text-muted); }
|
||||
.load-more { display: flex; justify-content: center; padding: 20px 0 8px; }
|
||||
.load-more-btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 28px; border-radius: 6px; border: 1px solid var(--desktop-border); background: transparent; color: var(--text-muted); font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; }
|
||||
.load-more-btn:hover:not(:disabled) { border-color: var(--border-gold-soft); color: var(--primary-light); }
|
||||
.load-more { display: flex; justify-content: center; padding: var(--space-5) 0 var(--space-2); }
|
||||
.load-more-btn { display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-2) 28px; border-radius: var(--radius-md); border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-sm); font-weight: 700; cursor: pointer; transition: border-color var(--duration-normal) var(--ease-smooth), color var(--duration-normal) var(--ease-smooth), box-shadow var(--duration-normal) var(--ease-smooth); }
|
||||
.load-more-btn:hover:not(:disabled) { border-color: var(--border-gold); color: var(--primary-light); box-shadow: var(--glow-gold); }
|
||||
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.end-hint { text-align: center; font-size: 12px; color: #444; padding: 16px 0 4px; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
|
||||
.load-more-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.end-hint { text-align: center; font-family: var(--font-body); font-size: var(--text-xs); color: #444; padding: var(--space-4) 0 var(--space-1); }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: var(--space-12) var(--space-5); color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-md); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,6 @@ function txLabel(tx: TxDetail): string {
|
||||
<template>
|
||||
<div class="desktop-account-page">
|
||||
<main class="account-main">
|
||||
<div class="detail-layout">
|
||||
<div class="page-header">
|
||||
<button type="button" class="back-btn" @click="goBack">
|
||||
‹ {{ t('common.back') || '返回' }}
|
||||
@@ -85,12 +84,20 @@ function txLabel(tx: TxDetail): string {
|
||||
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
|
||||
</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'">
|
||||
{{ formatMoney(detail.amount, locale) }}
|
||||
</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-row">
|
||||
<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-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
|
||||
</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">
|
||||
<span class="row-label">{{ t('wallet.balance_before') || '变动前余额' }}</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 v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -138,13 +149,6 @@ function txLabel(tx: TxDetail): string {
|
||||
|
||||
.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; }
|
||||
.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); }
|
||||
@@ -154,34 +158,76 @@ 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; }
|
||||
.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 {
|
||||
background: var(--desktop-sidebar-bg);
|
||||
border: 1px solid var(--desktop-border);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(212, 175, 55, 0.06), transparent 42%),
|
||||
rgba(18, 17, 14, 0.9);
|
||||
border: 1px solid rgba(255, 226, 156, 0.16);
|
||||
border-radius: 10px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.detail-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-size: 36px;
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.amount-hero.pos { color: var(--primary-light); }
|
||||
.amount-hero.neg { color: var(--danger); }
|
||||
|
||||
.type-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--desktop-border);
|
||||
.hero-summary {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.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-row {
|
||||
@@ -193,9 +239,13 @@ function txLabel(tx: TxDetail): string {
|
||||
|
||||
.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.pos { color: var(--primary-light); }
|
||||
.row-val.neg { color: var(--danger); }
|
||||
.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>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import api from '../../api';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import Pagination from '../../components/desktop/Pagination.vue';
|
||||
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
|
||||
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
|
||||
import { formatMoney } from '../../utils/localeDisplay';
|
||||
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const detailModalVisible = ref(false);
|
||||
const detailTxId = ref<string | null>(null);
|
||||
|
||||
function openDetail(txId: string) {
|
||||
detailTxId.value = txId;
|
||||
detailModalVisible.value = true;
|
||||
}
|
||||
|
||||
type Transaction = {
|
||||
transactionType: string;
|
||||
@@ -112,9 +120,8 @@ onActivated(() => fetchData(1));
|
||||
<tr
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId"
|
||||
class="hover-row"
|
||||
style="cursor: pointer"
|
||||
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
|
||||
class="hover-row clickable-row"
|
||||
@click="openDetail(tx.transactionId)"
|
||||
>
|
||||
<td class="type-cell">{{ txLabel(tx) }}</td>
|
||||
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
|
||||
@@ -140,16 +147,21 @@ onActivated(() => fetchData(1));
|
||||
<span>{{ t('wallet.no_records') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DesktopTxDetailModal v-model:visible="detailModalVisible" :transaction-id="detailTxId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.type-cell {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted-cell {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -172,6 +172,7 @@ function Invoke-PlayerThemeBuild {
|
||||
Write-Host "[INFO] Switched to $Branch"
|
||||
|
||||
Set-Location $Root
|
||||
$env:PLAYER_IMAGE_TAG = $Tag
|
||||
if ((Invoke-BuildExport -Service "player" -Tag $Tag) -ne 0) {
|
||||
Write-Host "[ERROR] Player build failed for $Branch"
|
||||
$script:FailedThemes.Add($Branch) | Out-Null
|
||||
|
||||
@@ -131,6 +131,8 @@ goto do_export
|
||||
:do_build
|
||||
echo ==^> Building %SERVICE% (tag: %TAG%)
|
||||
set "IMAGE_TAG=%TAG%"
|
||||
REM player 在 compose 里默认 PLAYER_IMAGE_TAG=main;与 --tag 对齐以便导出/部署
|
||||
set "PLAYER_IMAGE_TAG=%TAG%"
|
||||
if "%NO_CACHE%"=="1" goto build_no_cache
|
||||
if /i "%SERVICE%"=="all" (
|
||||
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build api player admin
|
||||
|
||||
@@ -13,6 +13,7 @@ INIT_DB=false
|
||||
SKIP_BACKUP=false
|
||||
ALLOW_DEFAULT_SECRETS=false
|
||||
IMAGE_TAR=""
|
||||
WITH_THEMES=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -30,6 +31,7 @@ usage() {
|
||||
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||
--themes 启用四套 player(player2/3/4);加载 thebet365-full-themes-*.tar 时自动开启
|
||||
--no-seed 不执行生产 seed
|
||||
--init-db 调用 scripts/prod-init-db.sh 清空业务数据并初始化生产数据,仅限全新库或明确重置
|
||||
--skip-backup 与 --init-db 一起使用,跳过 prod-init-db 的备份
|
||||
@@ -39,6 +41,7 @@ usage() {
|
||||
示例:
|
||||
./scripts/deploy-first.sh
|
||||
./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
./scripts/deploy-first.sh --images thebet365-full-themes-latest.tar --tag latest
|
||||
./scripts/deploy-first.sh --no-build
|
||||
./scripts/deploy-first.sh --init-db
|
||||
EOF
|
||||
@@ -59,6 +62,10 @@ while [ $# -gt 0 ]; do
|
||||
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--themes)
|
||||
WITH_THEMES=true
|
||||
shift
|
||||
;;
|
||||
--no-seed)
|
||||
RUN_SEED=false
|
||||
shift
|
||||
@@ -99,6 +106,12 @@ if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$WITH_THEMES" = true ]; then
|
||||
enable_themes_deploy
|
||||
elif [ -n "$IMAGE_TAR" ]; then
|
||||
maybe_enable_themes_from_tar "$IMAGE_TAR"
|
||||
fi
|
||||
|
||||
if [ -n "$IMAGE_TAR" ]; then
|
||||
load_image_tar "$IMAGE_TAR"
|
||||
fi
|
||||
@@ -118,8 +131,9 @@ if [ "$INIT_DB" = false ] && [ "$RUN_SEED" = true ]; then
|
||||
seed_production_if_missing_admin
|
||||
fi
|
||||
|
||||
log "启动 api / player / admin"
|
||||
compose up -d api player admin
|
||||
log "启动 $(stack_app_services)"
|
||||
# shellcheck disable=SC2046
|
||||
compose up -d $(stack_app_services)
|
||||
wait_for_stack_ready
|
||||
|
||||
if [ "$INIT_DB" = true ]; then
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$DEPLOY_LIB_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="$ROOT/docker-compose.prod.yml"
|
||||
COMPOSE_THEMES_FILE="$ROOT/docker-compose.themes.yml"
|
||||
ENV_FILE="$ROOT/.env.docker"
|
||||
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
|
||||
BACKUP_DIR="$ROOT/backups"
|
||||
@@ -12,6 +13,7 @@ CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env"
|
||||
PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env"
|
||||
|
||||
DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}"
|
||||
DEPLOY_WITH_THEMES="${DEPLOY_WITH_THEMES:-false}"
|
||||
LAST_DB_BACKUP=""
|
||||
LAST_UPLOADS_BACKUP=""
|
||||
|
||||
@@ -55,18 +57,95 @@ infer_image_tag_from_tar() {
|
||||
local base tag
|
||||
|
||||
base="$(basename "$image_tar")"
|
||||
tag="${base#thebet365-full-themes-}"
|
||||
tag="${tag%.tar}"
|
||||
if [ "$tag" != "$base" ] && [ -n "$tag" ]; then
|
||||
printf '%s' "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
tag="${base#thebet365-images-}"
|
||||
tag="${tag%.tar}"
|
||||
|
||||
if [ "$tag" != "$base" ] && [ -n "$tag" ]; then
|
||||
printf '%s' "$tag"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
is_full_themes_tar() {
|
||||
local image_tar="$1"
|
||||
case "$(basename "$image_tar")" in
|
||||
thebet365-full-themes-*.tar) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
themes_enabled() {
|
||||
[ "$DEPLOY_WITH_THEMES" = true ]
|
||||
}
|
||||
|
||||
player_image_tag() {
|
||||
local tag
|
||||
tag="$(env_value PLAYER_IMAGE_TAG)"
|
||||
tag="${tag:-main}"
|
||||
validate_image_tag "$tag"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
theme_player_tag() {
|
||||
local key="$1"
|
||||
local default_tag="$2"
|
||||
local tag
|
||||
tag="$(env_value "$key")"
|
||||
tag="${tag:-$default_tag}"
|
||||
validate_image_tag "$tag"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
compose() {
|
||||
local tag player_tag compose_args=(-f "$COMPOSE_FILE")
|
||||
tag="$(current_image_tag)"
|
||||
IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
|
||||
player_tag="$(player_image_tag)"
|
||||
|
||||
if themes_enabled; then
|
||||
[ -f "$COMPOSE_THEMES_FILE" ] || die "缺少 $COMPOSE_THEMES_FILE,无法启动四套 player"
|
||||
compose_args+=(-f "$COMPOSE_THEMES_FILE")
|
||||
fi
|
||||
|
||||
IMAGE_TAG="$tag" \
|
||||
PLAYER_IMAGE_TAG="$player_tag" \
|
||||
docker compose "${compose_args[@]}" --env-file "$ENV_FILE" "$@"
|
||||
}
|
||||
|
||||
stack_app_services() {
|
||||
if themes_enabled; then
|
||||
printf '%s' "api player player2 player3 player4 admin"
|
||||
else
|
||||
printf '%s' "api player admin"
|
||||
fi
|
||||
}
|
||||
|
||||
enable_themes_deploy() {
|
||||
DEPLOY_WITH_THEMES=true
|
||||
export DEPLOY_WITH_THEMES
|
||||
log "四套 player 主题部署已启用"
|
||||
}
|
||||
|
||||
maybe_enable_themes_from_tar() {
|
||||
local image_tar="$1"
|
||||
if is_full_themes_tar "$image_tar"; then
|
||||
enable_themes_deploy
|
||||
fi
|
||||
}
|
||||
|
||||
load_themes_from_release() {
|
||||
local val
|
||||
[ -f "$CURRENT_RELEASE_FILE" ] || return 1
|
||||
val="$(grep -E '^deploy_with_themes=' "$CURRENT_RELEASE_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
if [ "$val" = true ]; then
|
||||
enable_themes_deploy
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
@@ -203,6 +282,11 @@ wait_for_service_health() {
|
||||
wait_for_stack_ready() {
|
||||
wait_for_service_health api 180
|
||||
wait_for_service_health player 120
|
||||
if themes_enabled; then
|
||||
wait_for_service_health player2 120
|
||||
wait_for_service_health player3 120
|
||||
wait_for_service_health player4 120
|
||||
fi
|
||||
wait_for_service_health admin 120
|
||||
}
|
||||
|
||||
@@ -214,8 +298,13 @@ start_infra() {
|
||||
}
|
||||
|
||||
build_app_images() {
|
||||
if themes_enabled; then
|
||||
log "构建 api / player / player2 / player3 / player4 / admin 镜像"
|
||||
compose build api player player2 player3 player4 admin
|
||||
else
|
||||
log "构建 api / player / admin 镜像: $(current_image_tag)"
|
||||
compose build api player admin
|
||||
fi
|
||||
}
|
||||
|
||||
load_image_tar() {
|
||||
@@ -225,12 +314,28 @@ load_image_tar() {
|
||||
docker load -i "$image_tar"
|
||||
}
|
||||
|
||||
require_image() {
|
||||
local ref="$1"
|
||||
docker image inspect "$ref" >/dev/null 2>&1 || die "缺少镜像: $ref"
|
||||
}
|
||||
|
||||
require_images_for_current_tag() {
|
||||
local tag
|
||||
local tag player_tag t2 t3 t4
|
||||
tag="$(current_image_tag)"
|
||||
docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag"
|
||||
docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag"
|
||||
docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag"
|
||||
player_tag="$(player_image_tag)"
|
||||
|
||||
require_image "thebet365-api:$tag"
|
||||
require_image "thebet365-player:$player_tag"
|
||||
require_image "thebet365-admin:$tag"
|
||||
|
||||
if themes_enabled; then
|
||||
t2="$(theme_player_tag PLAYER2_IMAGE_TAG theme-2)"
|
||||
t3="$(theme_player_tag PLAYER3_IMAGE_TAG theme-3)"
|
||||
t4="$(theme_player_tag PLAYER4_IMAGE_TAG theme-4)"
|
||||
require_image "thebet365-player:$t2"
|
||||
require_image "thebet365-player:$t3"
|
||||
require_image "thebet365-player:$t4"
|
||||
fi
|
||||
}
|
||||
|
||||
run_prisma_migrations() {
|
||||
@@ -343,9 +448,10 @@ current_release_tag() {
|
||||
|
||||
record_release_state() {
|
||||
local source="${1:-deploy}"
|
||||
local tag stamp safe_tag manifest previous_tag
|
||||
local tag player_tag t2 t3 t4 stamp safe_tag manifest previous_tag
|
||||
|
||||
tag="$(current_image_tag)"
|
||||
player_tag="$(player_image_tag)"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env"
|
||||
@@ -361,9 +467,18 @@ record_release_state() {
|
||||
printf 'previous_tag=%s\n' "$previous_tag"
|
||||
printf 'deployed_at=%s\n' "$stamp"
|
||||
printf 'source=%s\n' "$source"
|
||||
printf 'deploy_with_themes=%s\n' "$DEPLOY_WITH_THEMES"
|
||||
printf 'api_image=thebet365-api:%s\n' "$tag"
|
||||
printf 'player_image=thebet365-player:%s\n' "$tag"
|
||||
printf 'player_image=thebet365-player:%s\n' "$player_tag"
|
||||
printf 'admin_image=thebet365-admin:%s\n' "$tag"
|
||||
if themes_enabled; then
|
||||
t2="$(theme_player_tag PLAYER2_IMAGE_TAG theme-2)"
|
||||
t3="$(theme_player_tag PLAYER3_IMAGE_TAG theme-3)"
|
||||
t4="$(theme_player_tag PLAYER4_IMAGE_TAG theme-4)"
|
||||
printf 'player2_image=thebet365-player:%s\n' "$t2"
|
||||
printf 'player3_image=thebet365-player:%s\n' "$t3"
|
||||
printf 'player4_image=thebet365-player:%s\n' "$t4"
|
||||
fi
|
||||
[ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP"
|
||||
[ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP"
|
||||
} > "$manifest"
|
||||
@@ -373,21 +488,40 @@ record_release_state() {
|
||||
}
|
||||
|
||||
print_stack_urls() {
|
||||
local player_port admin_port bind_addr tag
|
||||
local player_port player2_port player3_port player4_port admin_port bind_addr tag player_tag
|
||||
player_port="$(env_value PLAYER_PORT)"
|
||||
player2_port="$(env_value PLAYER2_PORT)"
|
||||
player3_port="$(env_value PLAYER3_PORT)"
|
||||
player4_port="$(env_value PLAYER4_PORT)"
|
||||
admin_port="$(env_value ADMIN_PORT)"
|
||||
bind_addr="$(env_value BIND_ADDR)"
|
||||
tag="$(current_image_tag)"
|
||||
player_tag="$(player_image_tag)"
|
||||
player_port="${player_port:-8082}"
|
||||
player2_port="${player2_port:-8083}"
|
||||
player3_port="${player3_port:-8084}"
|
||||
player4_port="${player4_port:-8085}"
|
||||
admin_port="${admin_port:-8081}"
|
||||
bind_addr="${bind_addr:-127.0.0.1}"
|
||||
|
||||
printf '\n'
|
||||
printf '%s\n' "部署完成:"
|
||||
printf '%s\n' " 镜像 tag: ${tag}"
|
||||
printf '%s\n' " api/admin 镜像 tag: ${tag}"
|
||||
printf '%s\n' " player 镜像 tag: ${player_tag}"
|
||||
if themes_enabled; then
|
||||
printf '%s\n' " 玩家端 main: http://${bind_addr}:${player_port}"
|
||||
printf '%s\n' " 玩家端 theme-2: http://${bind_addr}:${player2_port}"
|
||||
printf '%s\n' " 玩家端 theme-3: http://${bind_addr}:${player3_port}"
|
||||
printf '%s\n' " 玩家端 theme-4: http://${bind_addr}:${player4_port}"
|
||||
else
|
||||
printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}"
|
||||
fi
|
||||
printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}"
|
||||
printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问"
|
||||
if themes_enabled; then
|
||||
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker ps"
|
||||
else
|
||||
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps"
|
||||
fi
|
||||
printf '%s\n' " 当前发布: .deploy/current-release.env"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ NO_BUILD=false
|
||||
SKIP_BACKUP=false
|
||||
ALLOW_DEFAULT_SECRETS=false
|
||||
IMAGE_TAR=""
|
||||
WITH_THEMES=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -30,6 +31,7 @@ usage() {
|
||||
--pull 先执行 git pull --ff-only
|
||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||
--themes 启用四套 player(player2/3/4);加载 thebet365-full-themes-*.tar 时自动开启
|
||||
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||
--no-backup 跳过 PostgreSQL 与 uploads 备份
|
||||
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
||||
@@ -38,6 +40,7 @@ usage() {
|
||||
示例:
|
||||
./scripts/deploy-update.sh --pull
|
||||
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
./scripts/deploy-update.sh --images thebet365-full-themes-latest.tar --tag latest
|
||||
./scripts/deploy-update.sh --no-build
|
||||
EOF
|
||||
}
|
||||
@@ -57,6 +60,10 @@ while [ $# -gt 0 ]; do
|
||||
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--themes)
|
||||
WITH_THEMES=true
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=true
|
||||
shift
|
||||
@@ -92,6 +99,12 @@ if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$WITH_THEMES" = true ]; then
|
||||
enable_themes_deploy
|
||||
elif [ -n "$IMAGE_TAR" ]; then
|
||||
maybe_enable_themes_from_tar "$IMAGE_TAR"
|
||||
fi
|
||||
|
||||
if [ "$PULL_CODE" = true ]; then
|
||||
require_command git
|
||||
log "拉取代码: git pull --ff-only"
|
||||
@@ -121,8 +134,9 @@ fi
|
||||
|
||||
run_prisma_migrations
|
||||
|
||||
log "启动/更新 api / player / admin"
|
||||
compose up -d api player admin
|
||||
log "启动/更新 $(stack_app_services)"
|
||||
# shellcheck disable=SC2046
|
||||
compose up -d $(stack_app_services)
|
||||
wait_for_stack_ready
|
||||
show_prisma_status
|
||||
compose ps
|
||||
|
||||
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/deploy-lib.sh"
|
||||
TARGET_TAG=""
|
||||
SKIP_BACKUP=false
|
||||
ALLOW_DEFAULT_SECRETS=false
|
||||
WITH_THEMES="auto"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -25,9 +26,12 @@ usage() {
|
||||
注意:
|
||||
本脚本只回滚镜像,不回滚数据库结构或数据。
|
||||
如果目标版本涉及不可逆迁移,必须先按备份文件手工恢复数据库。
|
||||
若当前发布启用了四套 player,默认会一并回滚 player2/3/4;可用 --no-themes 仅回滚单套 player。
|
||||
|
||||
选项:
|
||||
--to TAG 目标镜像 tag,必填
|
||||
--themes 回滚时启用四套 player(player2/3/4)
|
||||
--no-themes 仅回滚单套 player,忽略当前发布的 themes 状态
|
||||
--no-backup 跳过 PostgreSQL 与 uploads 备份
|
||||
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
||||
-h, --help 显示帮助
|
||||
@@ -43,6 +47,14 @@ while [ $# -gt 0 ]; do
|
||||
TARGET_TAG="${2:?缺少 --to 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--themes)
|
||||
WITH_THEMES=true
|
||||
shift
|
||||
;;
|
||||
--no-themes)
|
||||
WITH_THEMES=false
|
||||
shift
|
||||
;;
|
||||
--no-backup)
|
||||
SKIP_BACKUP=true
|
||||
shift
|
||||
@@ -68,6 +80,16 @@ cd "$ROOT"
|
||||
require_docker
|
||||
ensure_env_file || exit 1
|
||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
||||
|
||||
if [ "$WITH_THEMES" = true ]; then
|
||||
enable_themes_deploy
|
||||
elif [ "$WITH_THEMES" = false ]; then
|
||||
DEPLOY_WITH_THEMES=false
|
||||
export DEPLOY_WITH_THEMES
|
||||
else
|
||||
load_themes_from_release || true
|
||||
fi
|
||||
|
||||
require_images_for_current_tag
|
||||
|
||||
start_infra
|
||||
@@ -80,8 +102,9 @@ else
|
||||
warn "已跳过 PostgreSQL 与 uploads 备份"
|
||||
fi
|
||||
|
||||
log "回滚 api / player / admin 到 tag: $(current_image_tag)"
|
||||
compose up -d api player admin
|
||||
log "回滚 $(stack_app_services) 到 tag: $(current_image_tag)"
|
||||
# shellcheck disable=SC2046
|
||||
compose up -d $(stack_app_services)
|
||||
wait_for_stack_ready
|
||||
show_prisma_status
|
||||
compose ps
|
||||
|
||||